Oh Dear is the all-in-one monitoring tool for your entire website. We monitor uptime, SSL certificates, broken links, scheduled tasks and more. You'll get a notifications for us when something's wrong. All that paired with a developer friendly API and kick-ass documentation. O, and you'll also be able to create a public status page under a minute. Start monitoring using our free trial now.

Adding try/catch to Laravel collections

Original – by Freek Van der Herten – 6 minute read

A few weeks ago, Jmac tweeted out an excellent idea. What if we could use try and catch in a collection chain?

Meanwhile, Jmac and I did a few code pairing sessions to work on a possible implementation. We've added try and catch methods to the laravel-collection-macros package.

In this blog post, I'd like to share what you can do with these methods and how they work under the hood.

Using try/catch in collection chains

After installing the laravel-collection-macros, which contains a lot of handy collection macros, you have access to the try and catch methods.

If any of the methods between try and catch throw an exception, the exception can be handled in catch.

collect(['a', 'b', 'c', 1, 2, 3])
    ->try()
    ->map(fn ($letter) => strtoupper($letter))
    ->each(function() {
        throw new Exception('Explosions in the sky');
    })
    ->catch(function (Exception $exception) {
        // handle exception here
    })
    ->map(function() {
        // further operations can be done, if the exception wasn't rethrow in the `catch`
    });

While the methods are named try/catch for familiarity with PHP, the collection itself behaves more like a database transaction. So when an exception is thrown, the original collection (before the try) is returned.

You may gain access to the collection within catch by adding a second parameter to your handler. You may also manipulate the collection within catch by returning a value.

$collection = collect(['a', 'b', 'c', 1, 2, 3])
    ->try()
    ->map(function ($item) {
        throw new Exception();
    })
    ->catch(function (Exception $exception, $collection) {
        return collect(['d', 'e', 'f']);
    })
    ->map(function ($item) {
        return strtoupper($item);
    });

// ['D', 'E', 'F']

How the methods work on under the hood

For try/catch to work, we needed a way of not immediately executing the methods calls after try. If they were executed immediately, we would have no way of catching exceptions that these methods might throw. The methods between try and catch should be executed when catch is reached.

We solved this problem by letting try not return a real collection but an instance of another class called CatchableCollectionProxy. This is done in Spatie\CollectionMacros\Macros\TryCatch class, which returns a callable that serves as the implementation of the Try macro.

namespace Spatie\CollectionMacros\Macros;

use Spatie\CollectionMacros\Helpers\CatchableCollectionProxy;

class TryCatch
{
    public function __invoke()
    {
        return function () {
            return new CatchableCollectionProxy($this);
        };
    }
}

With this in place, try returns an instance of CatchableCollectionProxy.

$collection = collect(['a', 'b', 'c', 1, 2, 3])
    ->try() // returns `CatchableCollectionProxy`
    ->map() // will be called on the catchable collection proxy
    ->catch() // will be called on the catchable collection proxy
    -> ...

Let's take a look at the implementation of CatchableCollectionProxy

namespace Spatie\CollectionMacros\Helpers;

use Closure;
use Illuminate\Support\Enumerable;
use ReflectionFunction;
use Throwable;

/**
 * @mixin \Illuminate\Support\Enumerable
 */
class CatchableCollectionProxy
{
    protected Enumerable $collection;

    protected array $calledMethods = [];

    public function __construct(Enumerable $collection)
    {
        $this->collection = $collection;
    }

    public function __call(string $method, array $parameters): self
    {
        $this->calledMethods[] = ['name' => $method, 'parameters' => $parameters];

        return $this;
    }

    public function catch(Closure ...$handlers): Enumerable
    {
        $originalCollection = $this->collection;

        try {
            foreach ($this->calledMethods as $calledMethod) {
                $this->collection = $this->collection->{$calledMethod['name']}(...$calledMethod['parameters']);
            }
        } catch (Throwable $exception) {
            foreach ($handlers as $callable) {
                $type = $this->exceptionType($callable);
                if ($exception instanceof $type) {
                    return $callable($exception, $originalCollection) ?? $originalCollection;
                }
            }

            throw $exception;
        }

        return $this->collection;
    }

    private function exceptionType(Closure $callable): string
    {
        $reflection = new ReflectionFunction($callable);

        if (empty($reflection->getParameters())) {
            return Throwable::class;
        }

        return optional($reflection->getParameters()[0]->getType())->getName() ?? Throwable::class;
    }
}

Let's dissect! This proxy class has a __call method. This magic method will be executed for each call to this class for which there doesn't exist an implementation. So, if you call for instance map on an instance of CatchableCollectionProxy, __call will get executed. It will receive "map" in the $method argument, all the parameters you give to the method in $parameters. We will keep both pieces of information in the calledMethods array. Essentially, we keep track of which methods get called on the proxy, without executing them.

The CatchableCollectionProxy does have a real method called. catch. When this method gets called, it will loop over all the entries in calledMethods and use the stored information to call the requested method.

This loop ins being done in a try/catch block. If any of the methods called throw an exception we can handle that. The exception will be passed to the $callable given to catch.

And that's all there is to it.

There one more interesting tidbit to look at. In normal circumstances, IDEs wouldn't be able to autocomplete collection methods anymore after the try method: the try method returns an object with only an implementation of catch.

This is solved by the mixin docblock at the top of the class.

/** @mixin \Illuminate\Support\Enumerable */

A mixin docblock hints to an IDE that every method available on the class mentioned in the docblock, is also available on the class where the docblock applies to. If you want to learn more about the mixin docblock, read this blog post.

Streaming sessions

As mentioned in the intro, Jmac and I created these macros together. We streamed all our sessions. You can watch the recordings below.

In the first session, Jmac and I coded up the solution.

In the next session, we reviewed the polished code and tests.

In the final session, we added the try/catch methods as macros in the laravel-collection-macros package.

I very much enjoyed doing these coding sessions with Jmac, and I hope I can do some more with him in the future.

In closing

The try/catch methods will be handy in a lot of situations. I hope that one day, these methods will be available in Laravel itself. Until then, install the laravel-collection-macros package to use them.

Also check out this list of packages that my team has created previously.

Stay up to date with all things Laravel, PHP, and JavaScript.

You can follow me on these platforms:

On all these platforms, regularly share programming tips, and what I myself have learned in ongoing projects.

Every month I send out a newsletter containing lots of interesting stuff for the modern PHP developer.

Expect quick tips & tricks, interesting tutorials, opinions and packages. Because I work with Laravel every day there is an emphasis on that framework.

Rest assured that I will only use your email address to send you the newsletter and will not use it for any other purposes.

Comments

What are your thoughts on "Adding try/catch to Laravel collections"?

Comments powered by Laravel Comments
Want to join the conversation? Log in or create an account to post a comment.