darkwood/ia-exception-bundle
Symfony bundle that replaces raw HTTP 500 stack traces with LLM-generated explanations, probable causes, and suggested fixes via Symfony AI. Configurable status codes, agent, timeout, cache, and async non-blocking analysis for readable, production-safe error pages.
composer requireThis is a Symfony bundle, not a Laravel package. The install will not complete in a Laravel app:
php: 8.5+ — Laravel 12 deploys on 8.2–8.4 in most shopssymfony: 8.x — it hooks kernel.exception, a kernel Laravel doesn't havesymfony/ai-bundle — the LLM plumbing Laravel has no binding forSo a Laravel developer "leverages" this package in exactly two ways:
Http + Cache + a queued job. For 2 stars and 0 dependents, this is usually the right call.Pick the sidecar only if you want the HTML error page and don't want to maintain the prompt. Pick the port if you care about your 500 latency.
composer create-project symfony/skeleton:^8.0 ai-errors in a separate repo/container.composer require darkwood/ia-exception-bundle symfony/ai-bundleconfig/packages/ai.yaml — ai.platform.openai.api_key + ai.agent.default.model: gpt-4o-miniconfig/packages/darkwood_ia_exception.yaml — start with enabled: true, async: true, include_trace: false, cache_ttl: 600resource: '@DarkwoodIaExceptionBundle/Resources/config/routes.yaml' — required or the /__ai_exception/{error_id} fetch 404s and users see the fallback foreverHttp::timeout(1)->acceptJson()->post('http://ai-errors/...')Copy the JSON shape verbatim — it's the most valuable thing in the repo:
{ "error_id": "...", "english_exception": "...", "probable_causes": [...], "suggested_fixes": [...], "confidence": 0.85 }
Then in bootstrap/app.php:
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (Throwable $e, Request $request) {
if (! config('ai-exception.enabled')) return null; // opt-in, like `enabled: true`
if ($request->expectsJson()) return null; // handled separately below
if ($e instanceof \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface
&& $e->getStatusCode() !== 500) return null; // only_status_codes: [500]
$analysis = app(ExceptionAnalyzer::class)->analyze($e); // returns null on any failure
if (! $analysis) return null; // fail-safe: default render
return response()->view('errors.500-ai', compact('e', 'analysis'), 500);
});
})
class, message, file, line, plus trace if enabled). Read it as a data-processing spec, not a marketing line.Local/staging: throw a RuntimeException('SQLSTATE[HY000] [2002] Connection refused') from a route, confirm you get causes and fixes. Then hit it in prod shape — Accept: application/json on an API route — and make sure you still get a 500 with your own envelope, not the AI one, unless you deliberately want the analysis in the payload.
render() (dev/staging only)Simplest, matches async: false. One HTTP call to the model inside the exception handler. Fine when the only consumer is you. Never ship this on a webhook or payment-callback endpoint: you've just added 800ms–3s to every failure response and your provider will retry.
This mirrors async: true + async_route_prefix. Return the page immediately, stash the context, let JS fetch the analysis:
// in the render callback, async branch
$id = bin2hex(random_bytes(8)); // 16 hex, same shape as the bundle's error_id
Cache::put("ai-exc:ctx:{$id}", [
'class' => $e::class, 'message' => $e->getMessage(),
'file' => $e->getFile(), 'line' => $e->getLine(),
], now()->addSeconds(config('ai-exception.async_context_ttl', 300)));
return response()->view('errors.500-ai-pending', ['errorId' => $id], 500);
// routes/web.php — keep it behind throttle and non-prod (or admin) gate
Route::get('/__ai_exception/{errorId}', AiExceptionController::class)
->middleware(['throttle:10,1'])
->name('ai-exception.resolve');
{{-- resources/views/errors/500-ai-pending.blade.php --}}
<div id="ai-analysis" data-src="{{ route('ai-exception.resolve', $errorId) }}">
<p>AI analysis loading…</p>
</div>
<script>
fetch(document.getElementById('ai-analysis').dataset.src, {headers: {Accept: 'application/json'}})
.then(r => r.ok ? r.json() : Promise.reject())
.then(d => {
// build DOM, do NOT innerHTML the model output
const el = document.getElementById('ai-analysis');
el.replaceChildren(renderAnalysis(d));
})
.catch(() => {
document.getElementById('ai-analysis').textContent = 'AI analysis unavailable.';
});
</script>
The 30s client-side timeout and graceful fallback in the README are worth copying — the fetch must never leave a spinner forever.
The bundle keys on (exception class, message, top frames hash). In Laravel:
$key = 'ai-exc:'.hash('xxh128', $e::class.'|'.$e->getMessage().'|'.implode('|', array_slice($frames, 0, 5)));
return Cache::remember($key, config('ai-exception.cache_ttl', 600), function () use ($e) {
return $this->callModel($e); // null on failure; don't cache nulls, or use a short negative TTL
});
Because 500s arrive in storms, this is the difference between 4 LLM calls and 4,000 during an outage. It is also the difference between a $2 month and a $200 month.
Best of both worlds. render() writes context + returns the placeholder; a AnalyzeExceptionJob (with $tries = 1, $timeout = 10, ->onQueue('ai')) does the model call and Cache::puts the result under the same error_id. The resolve route returns 202 until it's there, 200 when it is. This keeps the failure path completely off your HTTP workers — useful when the original 500 was caused by a saturated worker pool, which is exactly when a blocking call is most dangerous.
Honor $request->expectsJson() and return the bundle's exact envelope so existing tooling/dashboards that read probable_causes still work:
return response()->json([
'error_id' => $id,
'english_exception' => $analysis['english_exception'],
'probable_causes' => $analysis['probable_causes'],
'suggested_fixes' => $analysis['suggested_fixes'],
'confidence' => $analysis['confidence'],
], 500);
Ship this only for staff tokens — otherwise you're returning a narrative of your internals to whoever triggered the 500.
Keep the model call in one class with one job: never throw.
final class ExceptionAnalyzer
{
public function __construct(private Http $http) {}
/** @return array{english_exception:string,probable_causes:array,suggested_fixes:array,confidence:float}|null */
public function analyze(Throwable $e): ?array
{
try {
$res = $this->http->timeout(0.8)->connectTimeout(0.3)
->withToken(config('ai-exception.key'))
->acceptJson()
->post(config('ai-exception.endpoint'), [
'model' => config('ai-exception.model'),
'response_format' => ['type' => 'json_object'],
'messages' => [
['role' => 'system', 'content' => 'You explain PHP exceptions. Return JSON only: english_exception, probable_causes[], suggested_fixes[], confidence.'],
['role' => 'user', 'content' => json_encode([
'class' => $e::class, // these four fields only — see Gotchas
'message' => $this->sanitize($e->getMessage()),
'file' => $e->getFile(),
'line' => $e->getLine(),
])],
],
]);
$data = json_decode($res->json('choices.0.message.content') ?? '', true);
return is_array($data) && isset($data['probable_causes']) ? $data : null;
} catch (\Throwable) {
return null; // fail-safe: caller falls back to stock rendering
}
}
}
| Concern | Bundle key | Laravel equivalent |
|---|---|---|
| Opt-in | enabled |
config('ai-exception.enabled'), default false |
| Scope | only_status_codes: [500] |
guard on status code, don't widen |
| Timeout | timeout_ms (advisory!) |
Http::timeout(0.8) — actually enforced |
| Cache | cache_ttl, cache: 'cache.app' |
Cache::remember, TTL 0 ⇒ skip cache |
| Privacy | include_trace: false |
never include frames in prod |
| Async | async, async_context_ttl |
job + Cache::put TTL, min 60s |
The version wall is the whole story. PHP 8.5 + Symfony 8 + symfony/ai-bundle ^0.12. The changelog shows the AI dependency jumping 0.10 → 0.12 in two point releases (v8.1.1, v8.1.2). Pre-1.0 transitive deps mean the config schema and agent service IDs move under you. If you go the sidecar route, pin exact versions and treat upgrades as migration work.
timeout_ms does not enforce anything. The README says so explicitly — it's a target you satisfy by wiring a scoped HTTP client into ai.platform.openai.http_client. If you copy the config key and stop there, you have an unbounded blocking LLM call inside your error path. In Laravel, Http::timeout() is the enforcement; there is no equivalent "config-only" shortcut.
Fail-safe means you must catch \Throwable, not \Exception. A TypeError or a null from a malformed model response inside your render callback will replace a clean 500 with a fatal — and in Laravel, throwing from within the exception handler is how you get an unstyled white page with no logs. Also consider a static $handling re-entrancy guard.
Your exception messages are the leak surface, not the trace. The bundle sends only class/message/file/line — but you wrote the message. Real-world ones contain DSNs (mysql://user:pass@host), SQL with bound parameters, API keys in "Invalid token abc123", and customer emails. The bundle's sanitization stops at "don't send cookies/headers"; your message content is still on you. Run messages through a redaction pass (/password=[^&\s]+/, /Bearer \S+/, emails) before they leave the process. This is GDPR-relevant if a message echoes user input.
include_trace: true reveals absolute paths, vendor versions, internal hostnames. Default false, and if you flip it in dev, make certain the dev config cannot be loaded when APP_ENV=production. In Laravel terms: never if (config('app.debug')) around trace inclusion when debug accidentally leaks to prod — gate on app()->environment('local').
The async route is a public endpoint on your 500 path. error_id is a random 16-hex, but the payload describes your internals to anyone who has it, and a 500 page is exactly where an attacker gets it. Throttle it, restrict it to non-production or authenticated staff, log every hit with the id, and expire the context aggressively (async_context_ttl, min 60s per README). Don't use it as a cache-busting oracle.
Cache key on "top frames" recycles wrong analyses. Same exception class and message from two different root causes (a downed replica vs. a bad migration) share a fingerprint, so you'll confidently display stale causes. Mitigate with a shorter TTL on cached hits than you'd use for cold misses, or include a coarse discriminator (route name, job class) in the key.
Watch for cache-store collisions if you share Redis with a Symfony app. The bundle uses a PSR-6 pool (cache.app); Laravel uses PSR-16. Same Redis instance ⇒ different key encodings, no namespace isolation. Prefix everything (ai-exc:).
Never render model output with {!! !!}. The bundle injects into a Twig template; in Blade the temptation is innerHTML. LLM output can contain HTML, <script>, or markdown that mangles your layout. Use {{ }} / textContent, and cap the length of each string before display.
confidence is a vibe, not a probability. Don't gate any automation on confidence > 0.8. Treat the whole payload as a hypothesis — the bundle's own HTML page carries a disclaimer saying so. Keep it in the UI.
Only augment 500s. Widening only_status_codes to [404, 403] multiplies cost on the highest-volume traffic you have and gives users an LLM narrative for scanning. The bundle defaults to [500] for a reason.
Console and queue exceptions have no status code. only_status_codes is an HTTP concept; in a Laravel port, console failures flow through renderForConsole and queue failures through failed(). Decide deliberately whether you want analysis there — a nightly job failing 10,000 times should hit the cache, not the model 10,000 times. Add a circuit breaker / hourly cap (RateLimiter::attempt("ai-exc:{$key}", 20, 3600)) regardless.
Test the degradation path before you trust it. Unset the API key, point the endpoint at a black hole, and force a 500. You should get a fast, plain 500 with a logged reason. If you get a spinner, a stack trace, or a 30-second hang, the fail-safe is not wired.
Vendor risk is the meta-gotcha. Two stars, zero dependents, one maintainer, MIT, releases dated 2026. There is no community to catch a regression, and the README itself is the only docs. Budget for owning the prompt and the schema yourself — which is why the port is usually cheaper than the sidecar over a 12-month horizon. If you do adopt it, pin the tag and read every changelog between it and the next: v1.0.x, v8.0.x, and v8.1.x all coexist under "unified Darkwood versioning," so the numeric ordering will not tell you what's actually newer.
Extension points, briefly. Symfony-side: subclass the exception listener or override the error template. Laravel-side: the extension points are your own — an ExceptionAnalyzer interface (swap providers), a prompt class (tune for your domain: mention your stack, DB, queue driver), and a Blade partial (restyle). Keep all three separate or you'll be editing a 400-line handler at 2am during an incident.
How can I help you explore Laravel packages today?