Every Laravel developer has the same reflex.
Something breaks, you open the log, and you are met with a wall of stack trace. You scan for the one frame that is actually yours, read the message twice, and start reconstructing what the code was trying to do at the moment it gave up.
Most of the time you work it out. But that loop — exception, log, scan, reconstruct — is friction, and it repeats dozens of times a day.
I wanted to collapse it. So I built a small package that turns a Laravel exception into two things: a plain-language explanation for me, and a calm, safe message for the user staring at a 500 page.
It is called Laravel Error Explainer, and it is my first open-source package. This is what it does, the design decisions that mattered, and what shipping something public taught me that writing the code did not.
The two audiences of every exception
An exception has two audiences, and they want opposite things.
The developer wants detail: what broke, where, and the most likely fix, grounded in the actual code around the failure — not a generic "check your syntax" but a pointer at the specific line and a concrete next step.
The user wants none of that. They want a sentence that tells them something went wrong, without a stack trace, without jargon, and without blame.
Most apps serve one audience and neglect the other. You either leak technical detail onto a page a customer can see, or you swallow the error so completely that even you have to go digging.
The package generates both from the same exception:
- a dev message — what broke, the likely root cause based on the code shown, and a suggested fix, written to your log
- a user message — a short, non-technical line that is safe to render on an error page
You bring your own LLM key. It works with OpenAI, Anthropic, any OpenAI-compatible endpoint, or fully local with Ollama, and it can produce the output in any language.
That is the pitch. The interesting part is the constraints, because an error-handling tool plays by some unusual rules.
Rule one: the explainer must never crash the app it is explaining
This is the constraint that shaped everything else.
A tool that runs inside your exception handler is sitting in the most dangerous place in the codebase. If it throws while handling another throwable, you do not get a better error — you get a worse one, and you have turned a bad moment into a genuinely confusing one.
So the explainer catches everything. If the LLM call times out, the key is wrong, the provider is down, or the response comes back malformed, it logs a quiet warning and returns null. The original exception continues down Laravel's normal path exactly as if the package were not installed.
The rule I held myself to was simple: installing this package can make your errors more useful, but it can never make them worse. Nothing the explainer does is allowed to change whether your app stays up.
That single principle decided a dozen smaller questions later. Whenever I was unsure how to handle a failure, the answer was always "fail silent, let the real exception through."
Rule two: an exception in a hot loop should not cost you a fortune
Calling an LLM on every exception sounds reckless, and done naively it would be. An error firing ten thousand times inside a loop would be ten thousand API calls and a billing surprise.
So explanations are cached by a signature of the error, not the raw message. The signature is built from the exception class, file, line, and a normalised version of the message — numeric IDs are collapsed, so User 42 not found and User 99 not found share a single cache entry.
The same error site is explained once and served from cache after that. A loop that throws the same exception ten thousand times costs you exactly one call.
This is the kind of decision that is invisible when it works and painful when it is missing. Cost control cannot be bolted on later; it has to be part of the shape of the thing.
Rule three: know exactly what leaves your app
Sending stack traces and source snippets to a third-party API is not something to do casually, and I did not want to pretend otherwise.
Before anything is sent, the payload passes through a scrubber that redacts passwords, tokens, API keys, and email addresses by default. It is regex-based and best-effort, so you can add your own patterns for internal hostnames, customer identifiers, or anything else specific to your app.
And for teams that cannot send code off the box at all, there is a local driver. Point it at Ollama and inference stays entirely on your own machine — no payload ever leaves. That was non-negotiable for me. A debugging tool that quietly ships your source to someone else's server is not a tool I would install, so I made sure there was an answer for everyone who feels the same.
The provider question, and why I reached for a Manager
I did not want to marry the package to one provider.
Models change, prices change, and different teams have different constraints. The provider that makes sense today is not guaranteed to make sense in six months, and locking the whole package to one API would age badly.
So drivers resolve through a Laravel Manager — the same pattern the framework uses internally for Cache and Mail. There are built-in drivers for OpenAI, Anthropic, and Ollama, and adding your own is a few lines:
ErrorExplainer::llm()->extend('gemini', fn ($app) => new class implements LlmDriver {
public function complete(string $systemPrompt, string $userPrompt): string
{
// your HTTP call here
}
});
Then set the driver in your config and you are done. If your provider speaks the OpenAI API, you often do not even need a custom driver — you just point the OpenAI driver's base URL at their endpoint.
Using the framework's own extension pattern meant the package felt like Laravel rather than something bolted on beside it. That is a small thing that matters more than it sounds.
Sync when you are debugging, queue when you are serving
There are two ways to run it.
In sync mode the explanation is generated inline. That is what you want in local development — you trigger an error and the explanation is in your log a second later, right where you are already looking.
In queue mode the package serialises a scrubbed payload and dispatches a real queued job instead of blocking the request. The user's response is never held up waiting on an LLM. This is the mode you reach for if you ever run it beyond local, because a request should never wait on an external API to finish rendering.
The default is off in production unless you opt in, gated to your local and staging environments, so the safe thing happens if you install it and forget to configure it.
What shipping a public package actually taught me
Writing the code was the easy half.
The thing I underestimated was everything around the code. A package other people install has a completely different bar than an app only I deploy, and most of that bar has nothing to do with the feature itself.
Tests are not optional, and the interesting ones are the quiet parts. I did not write tests for the happy path so much as for the parts that are easy to get wrong without noticing: the scrubber actually redacting secrets, the cache serving the same signature only once, and the fail-safe guarantee genuinely returning null instead of throwing. Those are the behaviours that, if they broke silently, would undermine the whole promise of the package.
CI has to prove it works across versions, not just on my laptop. A package targeting Laravel 11 and 12 across several PHP versions needs a test matrix, because "works for me" is not a claim anyone else can rely on.
Static analysis and formatting are table stakes. Larastan and Pint are not there to impress anyone. They are there so that a contributor's first pull request has an objective standard to meet, instead of my personal taste.
The boring files carry real weight. A changelog, a contributing guide, and especially a security policy matter more for a package like this — one that sends data to third-party APIs — than they do for most projects. That is where you tell people what actually leaves their app and how to report a problem responsibly.
None of this is glamorous. All of it is the difference between "some code on GitHub" and "a package a stranger can trust enough to install."
What I would do differently
A couple of honest notes.
I would draw the privacy story even louder and earlier. The scrubbing and the local-first option are, to me, the most important part of the package, and they deserve to be the first thing a reader sees, not a section near the end.
And I would resist the urge to add drivers. It is tempting to ship support for every provider on day one. But a small, well-tested core with a clean extension point is worth more than a wide surface I cannot fully stand behind.
Try it
Laravel Error Explainer is open source on GitHub and installable from Packagist:
composer require kolakachi/laravel-error-explainer
Point it at a provider, or keep it fully local with Ollama, and your next uncaught exception comes with an explanation attached.
If you build Laravel apps, I would genuinely like to know whether the explanations hold up against your real, messy, production exceptions — the ones that are never as clean as the examples. That is the only test that actually counts, and it is the feedback I am watching for most.
Comments (0)
No comments yet
Be the first to share your thoughts!
Leave a Comment