Skip to content
Personal Experience Blogs
Go back

Three Layers. No More, No Less. | The Foundation Papers, Part 3

The first thing I check in a Laravel code review now is how many places in the codebase catch a fatal error and decide what to do about it. The honest answer, in most projects I’ve inherited, is “however many controllers exist.” Every controller has its own try-catch, its own judgment call about what the user sees, its own inconsistent logging. Nobody designed this distribution of responsibility. It’s just where the exceptions ended up, one controller at a time.

Scattered exception handling doesn’t fail loudly. It fails quietly, in the shape of the response. One endpoint returns a stack trace to production because someone forgot the try-catch. Another swallows the real error and returns a generic 500 with no way to trace it back to a log line. A third leaks a database constraint message straight to the client. None of these are the same bug. They’re all the same root cause: no one owns exception handling as a layer, so everyone owns a fragment of it, badly.

An exception hierarchy is not error handling. It’s a decision about who is allowed to decide what the user sees, made exactly once.

core-foundation collapses this into three layers, and each layer has exactly one job. ExceptionRenderer sits at the framework boundary and translates every exception Laravel itself can throw — validation failures, missing models, unauthorized requests, method-not-allowed — into the same response shape, before a single controller gets involved. BaseApiException is the layer for your domain — an InsufficientCreditsException or a TenantSuspendedException renders itself, because the exception knows what it means better than a generic catch block three layers away does. And ShouldntReport exists specifically for the exceptions that are expected business outcomes, not incidents — a duplicate email on signup does not need to page anyone or pollute your error tracker. The third layer, handleException() in the controller, is the fallback of last resort — the one place a truly unexpected fatal error gets a UUID and a 500, and nothing else touches it.

class InsufficientFundsException extends BaseApiException
{
    protected int $status = 402;
    protected string $message = "You don't have enough balance.";
}

// Controller — only for the truly unexpected
public function pay(PayRequest $request)
{
    try {
        return $this->successResponse(
            'Payment processed.',
            $this->paymentService->charge($request->validated()),
        );
    } catch (Throwable $e) {
        return $this->handleException($e); // last resort, not the first line of defense
    }
}

The exception renders itself. The controller never inspects what kind of failure it caught — it only exists as a backstop for the ones nobody predicted.

Three layers means three decisions, made once, instead of one decision made differently in every controller that happens to touch a try-catch. A new domain exception doesn’t require touching the renderer. A new framework edge case doesn’t require touching your domain exceptions. Each layer only knows about what’s actually its job.

The detail that mattered most in practice was separating “this failed and I need to know about it” from “this failed and the business already expected it.” Before ShouldntReport existed as a formal concept, every declined payment and every duplicate registration showed up in the same error channel as a genuine null-pointer bug — and eventually, the people watching that channel stopped reading it carefully, because most of what arrived wasn’t actually an emergency. An exception hierarchy that can’t tell the difference between a bug and a business rule trains your team to ignore both.

None of this is unique thinking. Most senior engineers arrive at some version of layered exception handling eventually, usually after the third incident caused by a controller that decided, alone, what a database constraint violation should look like to a customer. The value of writing it down as a base class isn’t the idea — it’s that the next engineer on the team inherits the decision instead of re-deriving it after their own incident.

Next: what happens when two requests try to update the same row at the same time, and why “just add a transaction” is not the whole answer.

This exception hierarchy is part of the free, MIT-licensed core — I want it to be how Laravel APIs handle errors, full stop, with no license standing in the way of that. The part I sell is what sits on top once your architecture is solid: profiling that tells you which of your three layers is actually slow, and scaffolding that wires all of this correctly before you’ve typed a line. That’s at https://packagist.org/packages/rupeshstha/core-foundation when you’re ready for it.

composer require rupeshstha/core-foundation — full hierarchy, what gets logged, and sensitive-field redaction: Exception Handling


Share this post on:

Previous Post
The Query That Corrupted Our Data. | The Foundation Papers, Part 4
Next Post
Your API Has No Shape. | The Foundation Papers, Part 2