pccomponentes/apixception-bundle
A Symfony bundle for consistent API exception handling, turning thrown exceptions into standardized JSON error responses with predictable HTTP status codes. Centralize exception-to-response mapping, reduce boilerplate, and keep API errors uniform across services.
1. Install and register (Symfony side).
composer require pccomponentes/apixception-bundle
As of v2.1.1 the package's Symfony constraint permits 7.4; nothing else changed in that release, so don't read it as a behaviour change. If you were previously holding --ignore-platform-reqs to install it, drop that workaround.
Symfony Flex usually adds the bundle to config/bundles.php for you. If not:
// config/bundles.php
return [
// ...
PcComponentes\ApixceptionBundle\ApixceptionBundle::class => ['all' => true],
];
2. Where to look first. Before writing any code, read three things in the package source, in this order:
DependencyInjection/Configuration.php — this is the authoritative list of config keys. There is no large documentation surface; the config tree is the docs.kernel.exception listener (usually EventListener/ExceptionListener.php or similar) — this tells you how it picks an exception, how it decides the status code, and what the JSON body looks like.3. First use case. The 80% case is: stop Symfony from returning an HTML error page to a JSON client.
// src/Controller/OrderController.php
public function show(string $id): Response
{
$order = $this->orders->find($id);
if (!$order) {
throw new OrderNotFoundException($id); // plain domain exception, no HTTP coupling
}
return $this->json($order);
}
Then register OrderNotFoundException → 404 with your custom payload in the bundle config. The controller stays framework-agnostic; the API contract lives in config. That separation is the whole value proposition — your domain layer throws domain exceptions, and one config file owns the HTTP mapping.
4. Verify with a functional test before anything else.
$client = static::createClient();
$client->request('GET', '/api/orders/nope', [], [], ['HTTP_ACCEPT' => 'application/json']);
$client->assertResponseStatusCodeSame(404);
$this->assertJsonPath('error.code', 'order_not_found'); // use your actual shape
If this passes, the bundle is wired and the listener priority is correct. If you get an HTML page, jump to Gotchas — this is almost always a listener-priority or framework.exceptions problem, not a bundle bug.
Throw domain exceptions; map them centrally. The strongest way to use this package is to keep Symfony\Component\HttpKernel\Exception\* out of your services and entities entirely. Define a small family of application exceptions (ValidationFailed, NotFound, Conflict, RateLimited) and map class → status → error code in one place. When the payload contract changes you edit one file, not forty throw statements.
Layer exception hierarchies. Mapping is usually resolved by most-specific-class, so a base class gives you a default and subclasses override:
AppException → 500, error.code = internal_errorDomainException extends AppException → 422OrderNotFoundException extends DomainException → 404This scales far better than a flat list of thirty exception classes, and it lets a new engineer add an exception without touching config if the defaults are sensible.
Keep the payload envelope stable and boring. Pick a shape and never change it:
{ "error": { "code": "order_not_found", "message": "...", "details": [] } }
code is the machine-readable field your clients switch on; message is for humans and can change freely. Clients that parse message are a support burden waiting to happen — document that in the API reference.
Carry context on the exception, not in the config. An exception that implements a toArray()/getContext() style hook lets you attach structured details (field-level validation errors, conflicting resource IDs) without the bundle needing to know your domain. If the bundle supports a context interface, use it; if not, a thin base class plus a small decorator is worth writing.
Scope it to the API surface. Don't let JSON error responses leak onto HTML routes, health checks, file downloads, or a webhook endpoint that expects plain text. Guard on Accept: application/json or on the URL prefix — the bundle may already do content negotiation, in which case verify that a browser Accept: text/html request still gets HTML. This is a five-minute test that prevents a very confusing incident.
Log and respond in the same event. Attach your own kernel.exception listener at a lower priority than the bundle's and log there. You want one record per unhandled exception with trace and request ID, regardless of whether it becomes a 404 or a 500. This is the difference between "the bundle renders errors" and "we can debug production".
Add CORS/security headers on kernel.response, not in the exception handler. The exception listener builds the response, then Symfony dispatches kernel.response as usual — so your existing CORS and header listeners still apply. If you find yourself manually setting Access-Control-Allow-Origin inside an exception path, you've duplicated logic that will drift.
Environment-aware detail. In dev/test, expose the exception class and trace; in prod, expose only the public code/message. Assert both paths in CI:
// tests/Api/ErrorContractTest.php
#[DataProvider('debugModes')]
public function test_unknown_exception_does_not_leak_trace(bool $debug): void { /* ... */ }
Day-to-day workflow. New endpoint → define or reuse an exception → add one config line → write one functional test asserting status + error.code. That loop is short enough that error contracts stop being an afterthought, which is the real payoff.
If you're actually on Laravel. This is a Symfony bundle: it registers via config/bundles.php and hooks kernel.exception. Laravel has neither bundles nor kernel events, so composer require will not give you working behaviour. Treat the package as a reference implementation and wire the equivalent in bootstrap/app.php:
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (OrderNotFoundException $e, Request $request) {
return $request->expectsJson()
? response()->json(['error' => ['code' => 'order_not_found', 'message' => $e->getMessage()]], 404)
: null;
});
})
Same pattern — central mapping, domain exceptions, one envelope — zero Symfony coupling. Only use the actual bundle if this lives in a Symfony/API Platform service inside the same monorepo.
The HTML error page means your listener never ran. This is the #1 failure mode. Check, in order: (1) the bundle is in config/bundles.php for the current APP_ENV; (2) the config file is config/packages/<root_key>.yaml and the key matches Configuration.php exactly; (3) listener priority on kernel.exception — if Symfony's own ErrorListener runs first and the exception has no mapped handling, you get HTML; (4) on Symfony 7.x, the framework.exceptions config and the new error-renderer path can intercept before your listener. Nudge your bundle's listener priority up if needed rather than reconfiguring the framework.
An exception thrown inside the exception listener falls back to the default page. If your payload-building code (a serializer, a DB lookup for an error reference, a translator) throws, you get the ugly HTML page and a confusing log. Keep handlers pure and side-effect-free: no database, no HTTP calls, no $this->getUser().
APP_DEBUG changes the payload. Symfony's debug handling can substitute a different response for 500s. Always test the contract with APP_DEBUG=0 as well as 1, and never let a debug trace reach a client. A trace with a DSN or a SQL string in it is a security incident, not a bug.
Exceptions raised before the controller still hit your mapping — sometimes. Router NotFoundHttpException and MethodNotAllowedHttpException occur in the routing/kernel phase, so they do flow through kernel.exception and will be mapped if you've configured them. But security exceptions (AccessDeniedException, AuthenticationException) are frequently consumed by the firewall's entry point first and never reach you. Test 401/403 explicitly; don't assume your config governs them.
Unmapped exception = your fallback, and your fallback must be 500 + logged. Verify that an arbitrary \RuntimeException produces a generic 500 envelope and not a 500 with the exception message. Then confirm it appears in your logs. Silent 500s are the worst outcome of adopting an error-handling package.
Don't map everything to 4xx. It's tempting to make unknown exceptions return 400 so dashboards look clean. Resist — you lose the ability to alert on server faults, and you'll mislead clients into not retrying.
Header/cookie side effects on error responses. Errors emitted from kernel.exception skip controller-level middleware but still get kernel.response. If a session cookie or a Content-Type: application/problem+json needs to be set, do it in a response listener, not in the exception config.
Version-pin it, and read the changelog, not the release note. v2.1.1 is a constraint-only release — no behaviour changed, so the error payload you get depends on whichever earlier version you actually resolved. Pin an exact version in composer.lock, and treat the package as small and low-traffic (0 stars, 0 dependents): it is not going to be maintained for you. Wrap the config in your own exception base classes and a single ErrorResponder seam so that if you have to fork or replace it, you change one file.
Check the Symfony floor before adopting. v2.1.1 widened the upper constraint to 7.4; confirm the lower bound hasn't moved in a way that strands your oldest deployment target, and confirm your CI actually runs the Symfony minor you're resolving to — a widened constraint the package's own matrix doesn't exercise is a promise, not a guarantee.
Extension point worth knowing: if the built-in mapping is too rigid, most bundles of this shape let you register a handler/mapper service by tag or interface. Before forking, search the bundle's Resources/config/ (or config/) for a service tag you can implement — decorating one service is a far cheaper exit strategy than a fork.
How can I help you explore Laravel packages today?