Exceptions that explain themselves original

by Freek Van der Herten – 3 minute read

There There, the helpdesk we're building at Spatie, is a project of learned habits. Twenty-some years of writing Laravel apps has taught us which patterns pay off and which ones quietly rot. One that keeps earning its keep is a custom exception class for every domain error.

This is a small pattern, but it's one of those things that makes a codebase nicer to live in. There There is in private beta right now, and you can apply for early access at there-there.app.

Exceptions that explain themselves

The conventional answer for a domain error is something like throw new InvalidArgumentException("Only notes can be deleted"). It works, the test passes, the user sees a 500 page. But then three months later you try to catch just this specific error and you can't, because InvalidArgumentException is thrown from a dozen other places.

Our pattern is one class per domain error, with a static factory method per reason. Here's the one that fires when someone tries to delete a message that isn't a note.

namespace App\Domain\Tickets\Exceptions;

use App\Domain\Tickets\Models\Message;
use Exception;
use Illuminate\Contracts\Debug\ShouldntReport;

class CouldNotDeleteMessage extends Exception implements ShouldntReport
{
    public static function onlyNotesCanBeDeleted(Message $message): self
    {
        return new self("Could not delete message id {$message->id} of type {$message->type->value}. Only messages of type `note` can be deleted.");
    }
}

Two things worth calling out. The static method name spells out the failure reason in English, which makes the throw site read like a sentence: throw CouldNotDeleteMessage::onlyNotesCanBeDeleted($message). And the method takes the typed context as a parameter, which makes the message impossible to build without the relevant data.

Here's the throw site, inside our DeleteNoteAction.

class DeleteNoteAction
{
    public function execute(Message $message): void
    {
        if (! $message->isNote()) {
            throw CouldNotDeleteMessage::onlyNotesCanBeDeleted($message);
        }

        // ... delete the note
    }
}

When the throw site is this readable, you don't need a comment explaining what went wrong. The exception name, the method name, and the message all agree with each other.

The ShouldntReport marker is a small Laravel convention. We implement it so these exceptions don't end up in our error tracker. They're expected domain errors, not bugs, and they surface through user action. Keeping them out of the error log makes the signal there actually signal.

If a domain has several failure modes, we add methods. Our CouldNotInviteMember class has half a dozen static factories, one per reason the invitation couldn't be sent. Every throw site is a one-liner that tells you exactly what went wrong.

In closing

Custom exceptions look like over-engineering until the first time you need to catch one. Then they look like the obvious move. And because every exception class is tiny, they don't add much weight to the codebase.

If you'd like to see how the rest of There There is put together, we're in private beta right now and you can apply for early access at there-there.app.

Join 9,500+ smart developers

Get my monthly newsletter with what I learn from running Spatie, building Oh Dear, and maintaining 300+ open source packages. Practical takes on Laravel, PHP, and AI that you can actually use.

No spam. Unsubscribe anytime. You can also follow me on X.

Found something interesting to share? Submit a link to the community section.