Adding try/catch to Laravel collections
A few weeks ago, Jmac tweeted out an excellent idea. What if we could use try
and catch
in a collection chain?
Collections are the jam. Yet they leave me dreaming of more.
— Jason McCreary (@gonedark) June 1, 2020
Take this block that performs some custom validation logic by leveraging a value object constructor.
Collections definitely streamline it, but what if I could also chain the exception handling… 🔥 pic.twitter.com/4jj0uFgwWb
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.
What are your thoughts on "Adding try/catch to Laravel collections"?