riper/exception-transformer
Exception Transformer for Laravel turns thrown exceptions into consistent, structured responses. Map exception types to custom transformers to control status codes, messages, and error payloads, so API error output stays predictable and your exception handlers stay clean.
This is a tiny, low-traffic bundle (0 stars; latest release is v2.0.0, a maintenance drop that only bumps the compatibility floor to PHP 7.4+ and Symfony 4+). Treat it as a reference implementation more than a dependency you build a platform on. The core idea is: register one or more "transformers" that map a low-level exception (PDO, Guzzle, Doctrine, a vendor SDK exception) into a domain exception your app already understands, and do it before kernel.exception listeners and the error controller run.
Minimal steps:
composer require riper/exception-transformer:^2.0Riper\ExceptionTransformerBundle\ExceptionTransformerBundle::class => ['all' => true] to config/bundles.php.TransformerInterface (typically a supports(\Throwable): bool + transform(\Throwable): \Throwable pair),DependencyInjection/ for the tag name, e.g. exception_transformer.transformer),final class PdoToDomainTransformer implements TransformerInterface
{
public function supports(\Throwable $e): bool
{
return $e instanceof \PDOException
&& str_contains($e->getMessage(), 'Duplicate entry');
}
public function transform(\Throwable $e): \Throwable
{
return new EmailAlreadyUsedException($e->getMessage(), 0, $e);
}
}
First use case that pays for itself immediately: stop leaking PDOException / UniqueConstraintViolationException into your kernel.exception listener and API responses. Transform once, and every existing listener, ProblemNormalizer, Sentry filter, and ExceptionSubscriber sees the domain type.
One transformer per vendor boundary. Keep supports() narrow (class check + message/code check). A transformer that matches "any \Throwable" will swallow everything and make debugging miserable.
Always chain the previous exception. new MyDomainException($msg, 0, $e) — otherwise you lose the original stack and the bundle's transformation becomes a bug-hiding machine.
Order transformers by specificity. If the bundle supports multiple matches and priority, register the most specific ones first (or rely on tag priority). The generic fallback (e.g. "any Doctrine\DBAL\Exception → PersistenceException") should run last.
Pair with a single kernel.exception listener that maps domain exceptions to HTTP status codes. The transformer gives you clean types; your listener stays a plain match over those types and never touches PDOException. This is the real day-to-day leverage: HTTP concerns stay in the listener, persistence concerns stay in the transformer.
Use it at the boundaries you don't control, not for exceptions you throw yourself. Good candidates: DBAL, Redis/PhpRedis, HTTP clients (GuzzleHttp\Exception\ConnectException), filesystem (Symfony\Component\Filesystem\Exception\IOException), messenger transport exceptions. Bad candidates: your own service-layer exceptions — just throw the right one.
Test transformers in isolation. They're pure functions; unit-test supports() and transform() directly. Add one integration test that hits a controller throwing a PDOException and asserts the JSON error body / status — this catches the "listener priority is wrong and my transformer never ran" class of bug.
Console and Messenger are separate paths. The bundle hooks the HTTP kernel, so bin/console and messenger:consume failing jobs won't go through it. If you need symmetry, wrap the same transformers in a Messenger MessageFailedEvent listener or a console error listener yourself.
kernel.exception subscribers. If your exception listener is registered at default priority and the bundle at default priority, order is registration-dependent and can silently flip between environments. Explicitly set priorities on your own listeners and verify with php bin/console debug:event-dispatcher kernel.exception.setThrowable() semantics. The kernel event lets listeners swap the exception. Anything that captured the exception before this listener (e.g. a monolog FingersCrossed handler attached earlier, a kernel.terminate logger) sees the original. Expect log/Sentry entries that don't match your HTTP response — annotate the transformed exception with a previous chain so it's traceable.transform() returns something another transformer also supports(), you can bounce between them. The bundle likely applies transformers once; don't rely on it — make returned domain exceptions not satisfy any supports() you own.\Error subtypes (TypeError, Error) into 4xx domain exceptions. They're bugs; leave them as 500s.^2.0 deliberately. Nothing in the transformer/listener contract changed, so your transformers and tests should port unchanged; just make sure CI runs on PHP ≥7.4.ContainerBuilder autoconfiguration and KernelInterface signatures. Verify in a scratch project before committing; you may be patching a 40-line listener.EventSubscriber on KernelEvents::EXCEPTION at priority 256 — that's the whole feature.debug:container --tag=<tag>), (3) does supports() actually match (log the class + message), (4) is a higher-priority listener setThrowable()-ing first.TraceableTransformer (collects which transformer fired into the profiler) rather than editing the bundle — you'll want that data the first time a production error returns an unexpected status code.How can I help you explore Laravel packages today?