fab2s/context-exception
A lightweight context-aware exception package for PHP and Laravel that lets exceptions carry structured contextual data, making errors easier to log, debug, and present with meaningful details across your applications.
Install and read two files, not the whole package.
composer require fab2s/context-exception
Then open, in this order:
src/ContextExceptionInterface.php — the contract (getContext(), setContext(), addContext() in some shape).src/ContextExceptionTrait.php — the actual behavior. This is the file that matters, because it is what you will reuse in your own exception classes. The bundled ContextException class is just the trait applied to \Exception.The entire value proposition is one idea: an exception that carries a structured array alongside its message, so you stop encoding data into string messages.
First use case — kill your string-concatenated messages. Find a place where you currently do this:
throw new \RuntimeException("Payment failed for order {$order->id} via {$gateway}");
Replace it with:
throw new ContextException('Payment failed', [
'order_id' => $order->id,
'gateway' => $gateway,
'amount' => $order->total,
'attempt' => $attempt,
]);
You gain nothing visible yet — the win shows up in the second consumer: your log handler, your API error renderer, and your test assertions can all read getContext() instead of regex-ing a message string. That is the whole reason to adopt it, so pick your first use case where the context is actually consumed downstream (a report handler or a JSON error response), not just where it is thrown.
Verify the constructor signature in the source before writing factories. Small packages like this vary on argument order (context as second arg vs. fourth after $code/$previous). If it is ambiguous, skip direct instantiation entirely and use the trait on your own base exception — that path is stable regardless.
The scalable move is not to throw ContextException everywhere (you lose type-based catch and per-domain rendering). Instead, apply the trait once:
namespace App\Exceptions;
use Fab2s\ContextException\ContextExceptionTrait;
class DomainException extends \Exception
{
use ContextExceptionTrait;
}
Then every domain exception in the app gets context for free:
namespace App\Exceptions;
class InventoryShortage extends DomainException
{
public static function forSku(string $sku, int $requested, int $available): self
{
return (new self('Insufficient inventory'))
->setContext(compact('sku', 'requested', 'available'));
}
}
Now catch (DomainException $e) still works, instanceof checks stay meaningful, and context is an implementation detail of the base class. If the package ever disappears, you reimplement three methods on one class.
The single biggest maintainability factor with context exceptions is whether the context shape is defined in one place. Named constructors (as above) do this. Inline new InventoryShortage('...', [...]) scattered across five services does not — you end up with sku in one place, product_sku in another, and an alerting rule that matches neither.
If you want a hybrid, add a contextSchema()-style guard in the base class that asserts required keys in non-production, or just add typed properties to the exception and build the array in the constructor.
Context shines when each layer adds what it knows:
// repository
throw OutboundHttpFailed::forEndpoint($url, $status); // url, status
// service — catch, add domain ids, rethrow
try {
$this->client->send($payload);
} catch (OutboundHttpFailed $e) {
throw $e->addContext(['order_id' => $order->id, 'attempt' => $attempt]);
}
// controller / job — add correlation
catch (OutboundHttpFailed $e) {
throw $e->addContext(['request_id' => $requestId]);
}
This is where addContext() (mutating, returns $this) vs. a withContext()-style immutable copy matters — check which the package gives you. Mutation-on-rethrow is fine and simpler; immutable copies are safer if the same exception object is caught twice (rare, but it happens in retry loops).
Always pass $previous when you wrap a different exception type. Losing the original trace is the most common self-inflicted wound in exception layering.
In bootstrap/app.php (Laravel 11+) or App\Exceptions\Handler:
->withExceptions(function (Exceptions $exceptions) {
$exceptions->report(function (DomainException $e) {
if ($context = $e->getContext()) {
Log::error($e->getMessage(), $context + [
'exception' => $e::class,
'code' => $e->getCode(),
]);
}
});
})
One place converts context into structured log fields, which is what makes the data queryable rather than decorative. Return false from the report callback if you've fully handled logging and don't want the default handler to log a duplicate record.
For a JSON API, map context into the payload — but through an explicit allowlist, per exception type, not getContext() verbatim:
class InventoryShortage extends DomainException
{
public function render($request)
{
return response()->json([
'error' => 'inventory_shortage',
'message' => $this->getMessage(),
'details' => Arr::only($this->getContext(), ['sku', 'requested', 'available']),
], 409);
}
}
Implementing render() on the exception is the neatest Laravel integration: controllers stay thin, and the exception owns its own HTTP representation.
This is the highest-ROI daily use. Instead of expectExceptionMessageMatches() gymnastics:
try {
$this->postJson('/api/orders', $payload);
$this->fail('Expected InventoryShortage');
} catch (InventoryShortage $e) {
$this->assertSame('SKU-1', $e->getContext()['sku']);
}
Or with $this->expectException() plus a small custom assertion helper on your base TestCase. Tests that assert on context stay green when you reword the human-readable message — a real reduction in test churn.
Laravel may already have this. Recent Laravel versions ship Exception::context() for structured logging. If you're on a version that has it, adopting a second, parallel convention means two ways to express the same thing and neither is reliably populated. Decide deliberately: use the package if you need context on domain exceptions that you also catch and inspect programmatically (context as data), and use the framework feature if you only need it for logs. Don't ship both.
Context is not logged or rendered for free. Nothing in this package knows about your logger. If you don't add a report hook (Pattern 4) or a render() (Pattern 5), the context is inert data that only shows up in var_dump. Many teams adopt the package, throw context-rich exceptions, and then wonder why logs look identical. Wire the consumer first, then start throwing.
Don't put non-serializable values in context. Queue failures serialize the exception into failed_jobs. Closures, PDO handles, Eloquent models with live connections, and streams will break or bloat that serialization. Store identifiers ($order->id, $order->getKey()), not $order. The same rule applies if you ever cache/persist exception context.
Everything in context is a potential secret leak. Passwords, tokens, full request bodies, card PANs, and PII all find their way into a catch block's convenient $request->all(). Build the scrub list before the first production incident, not after: either filter at the throw site (preferred — the throwing code knows what's sensitive), or add a single SanitizesContext trait method applied in the report hook. Note that context may end up in logs, in failed_jobs (DB), and in HTTP responses — three different exposure surfaces with different rules.
You now have duplication pressure with getMessage(). If the message says "Payment failed for order 42 via stripe" and the context has order_id/gateway, you've doubled the maintenance and the log line. Move toward short, stable, human-facing messages ("Payment failed") with all variable data in context. Watch out for anything downstream that parses the message (alerting rules, monitoring string matches) — those break silently when you shorten messages.
Exception messages get truncated; context usually doesn't. Another argument for putting bulk data in context. But check your log sink's field limits — a context array with a 200KB HTML error body from an upstream API will get dropped or truncate the whole record.
Trait method collisions. getContext() / setContext() are generic names. If a base exception class in your hierarchy already defines them (or a parent uses a trait with the same methods), PHP will fatal on conflict. Resolve explicitly with insteadof/as, or rename via the trait aliasing. Check this the moment you introduce the trait below an existing exception base.
Verify addContext() semantics before relying on it. Mutating (returns $this) versus immutable (returns a clone) changes your code shape at every rethrow site. Getting this wrong gives you throw $e->addContext(...) returning null and a confusing "exception must be Throwable" error. One var_dump-or-read-the-source check saves an hour.
Adoption risk is low but real. Three stars, zero dependents, one maintainer. The mitigation is trivial: the surface is a trait with a handful of array methods. Copy it into app/Exceptions/Concerns/HasContext.php if you want zero dependency, or accept composer require and know that a fork or inline copy is a 15-minute job. Either way, do not let ContextException (the concrete class) leak into your public API or domain layer — always extend your own base exception. That single rule makes the dependency swappable.
Extension point to actually build: a contextKeys() or redactedKeys() static on your base exception, consumed by the report hook and the renderer. This gives you one place to declare, per exception type, what is logged, what is rendered, and what is never emitted — which is the thing this package doesn't give you and you will need within a month.
When not to use it. For a one-off throw new \RuntimeException('config missing') in a bootstrap path, adding context is ceremony with no consumer. Reserve structured context for exceptions that cross a layer boundary, get reported, or get asserted on in tests.
How can I help you explore Laravel packages today?