sycho/errors
sycho/errors is a Laravel/PHP error-handling package that helps developers capture, organize, and present application errors. It centralizes error management, supports cleaner debugging workflows, and makes it easier to build consistent error responses and user-facing messages.
Read before you install. This package sits on the error path of every request, and it has 0 stars, 0 dependents, and a single maintainer. Before composer require, open the repo and answer three questions in ten minutes: what are the php and illuminate/* constraints in composer.json, does it register a ServiceProvider that replaces or decorates App\Exceptions\Handler, and how many source files are there? If the answer to the last one is "five or fewer," you are evaluating a snippet, not a dependency — plan accordingly.
Install pinned.
composer require sycho/errors:1.0.0 # exact pin, no caret, until validated
A floating constraint on an unvalidated single-maintainer package means a silent behavior change lands in a patch release you didn't intend to take.
Look here first, in this order:
ServiceProvider — this tells you whether it swaps the handler, binds a contract, or just publishes config/views. That single file decides whether removal is a composer remove or an archaeology project.Publish the assets (tag names vary — confirm against the provider):
php artisan vendor:publish --tag=errors-config
php artisan vendor:publish --tag=errors-views
Publish the views even if you like the defaults. Unpublished views are a runtime dependency on a package nobody is patching; published views are code you own.
First real use case: take the try/catch + abort(404) + ad-hoc JSON error arrays scattered across one controller and move them into the mapping. The canonical first target is ModelNotFoundException — map it once to a 404 view for browsers and a {"error": {"code": "...", "message": "..."}} envelope for expectsJson() requests, delete the three places you were handling it, and confirm both paths in the browser and with curl -H "Accept: application/json".
Baseline check: before adopting, write down what Laravel's native Handler::renderable() would need — realistically ~30 lines in bootstrap/app.php or your handler. If the package's value isn't clearly above that line after the first use case, stop there and keep the 30 lines.
Extend a package base class, don't implement an interface directly. Define your own App\Exceptions\DomainException that extends the package's base exception and carries an error code plus a context array. Every call site in your domain layer throws your class. This is the adapter-first pattern: if you later fork or replace the package, only DomainException changes, not the hundreds of throw new ... statements.
final class PaymentDeclined extends DomainException
{
public function __construct(string $orderId, string $reason)
{
parent::__construct(
code: ErrorCode::PAYMENT_DECLINED,
message: 'Payment was declined.',
context: ['order_id' => $orderId, 'reason' => $reason],
status: 402,
);
}
}
Register mappings centrally, one place. The whole pitch of the package is that exception→response logic lives in a config map or a single registration method rather than in per-exception render() methods. Lean into that: one file listing every mapping, reviewed in PRs like a route file. If you find yourself adding render() methods back onto exception classes, you've defeated the point and should just use Laravel's native renderable().
Separate render from report. Rendering is what the client sees; reporting is what your logs and Sentry see. Keep context rich on the report side (IDs, states, correlation IDs) and deliberately thin on the render side. In production the render output should never leak the context payload — verify with APP_DEBUG=false explicitly, because a package that says "environment-aware output" has exactly one place where it can go wrong, and it's this one.
Branch on content negotiation, not on environment. Web requests get a view; $request->expectsJson() gets the envelope. Don't gate the response shape on APP_DEBUG — gate the detail level on it. A frontend that gets HTML during a local debug session and JSON in production is a debugging nightmare.
Strangle one module at a time. Pick the module with the messiest existing handling (usually billing or auth), migrate it, and measure. If the diff removes more try/catch lines than it adds config lines, broaden. If it doesn't, you've learned that cheaply.
Stable error codes as an enum. The thing integrators actually code against is the code string, not the message. Treat the code enum as a public API: append-only, documented, never renumbered. This is the one genuinely valuable asset you get out of the exercise, and it survives whatever happens to the package.
Test the contract, not the implementation.
public function test_declined_payment_returns_structured_error(): void
{
$this->postJson('/api/pay', [...])
->assertStatus(402)
->assertJsonPath('error.code', ErrorCode::PAYMENT_DECLINED)
->assertJsonMissingPath('error.trace');
}
Run this suite with APP_DEBUG both true and false. That pair of tests is what will catch a silent behavioral change on your next composer update — and given the maintainer situation, it is the single highest-value artifact in the whole adoption.
Integration tips: register your mappings in a dedicated ExceptionServiceProvider or module provider rather than in App\Exceptions\Handler, so the wiring is visible and deletable. Keep Handler::render() as the final fallback and never let the package take over the report() path.
Assume you are the maintainer. Zero stars, zero dependents, no visible CI. This is not a reason to walk away — the code is small enough to read end-to-end in an hour — but it does mean the fork contingency should be pre-planned, not improvised at 2am. Before you rely on it, confirm vendor/sycho/errors/src fits comfortably in app/Support/Exceptions under your own CI. If it does, vendoring is a legitimate and arguably better outcome than depending.
Handler collisions are the top failure mode. spatie/laravel-ignition, Sentry, Flare, and any error-envelope package all hook the same handler methods. If two packages both claim a mapping for the same exception class, the loser fails silently and the error can leak out as a generic 500. Order of registration matters; verify by deliberately throwing a mapped exception after installing each of those packages.
Never put unserializable values in context. Closures, resources, UploadedFile instances, and raw Eloquent models will blow up during serialization — producing a secondary exception inside error reporting, which is the hardest class of bug to debug because your error handling is the thing that's broken. Context should carry scalars: IDs, codes, enum values, counts. Never request bodies, never PII.
Publish and own the views. Package-provided error views are a liability — you cannot rely on them being updated, and they may reference helpers or asset paths that don't match your layout. Publish them, strip them to your design system, and treat the vendor copies as read-only reference.
Know which render wins. Laravel's renderable() closures, a render() method on the exception, and the package's mapping are three competing mechanisms. Establish a rule — "all mappings live in the config map, no render() methods on exceptions" — and enforce it in review. Without that rule, a new hire will add a render() method and be confused when it's ignored.
Exceptions thrown inside render are unrecoverable. If your mapped view calls a helper that throws, or the config references a view that doesn't exist after a rename, you get "error while rendering the error." Test every mapping by actually hitting it — php artisan route:list won't tell you a Blade path is stale.
Console and queue contexts are not web contexts. A mapped response designed for HTTP makes no sense in a queued job or an Artisan command. Verify that the CLI path still produces a readable message with a stack trace; if the package applies HTTP-shaped rendering globally, that's a sign it's reaching too far into the lifecycle.
Watch the illuminate/* constraint at every Laravel major. A tight pin becomes an upgrade tax; a loose one becomes an untested code path. Either way, budget a check at each major and each PHP minor, and let the contract test above be the gate.
Debugging tip: when a mapped exception renders the wrong thing, log the exception class name and the resolved mapping key at throw time in local. Ninety percent of "the mapping isn't working" cases are a subclass being thrown where the parent was mapped, or a namespace mismatch between two identically-named exception classes across a refactor.
Extension points worth knowing: whatever the package exposes for registering custom handlers, plus the config map itself. Those two are the whole extensibility surface — everything else is a base class you're extending. If you need behavior beyond that, that's your signal to fork, because the package is small enough that forking is cheaper than negotiating an upstream that isn't listening.
How can I help you explore Laravel packages today?