hradigital / php-exceptions-laravel
Laravel wrapper for hradigital/php-exceptions, wiring Laravel's error handling and providing a JSON Renderer for these Exceptions.
Package info
github.com/HRADigital/php-exceptions-laravel
pkg:composer/hradigital/php-exceptions-laravel
Requires
- php: ^8.2
- hradigital/php-exceptions: ^1.0
- illuminate/http: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
- symfony/http-foundation: ^7.0 || ^8.0
Requires (Dev)
- hradigital/php-static-analysis: ^1.0
- orchestra/testbench: ^10.0 || ^11.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.5 || ^12.0
- squizlabs/php_codesniffer: ^3.7
README
Laravel wiring and JSON / web renderers for the hradigital/php-exceptions domain exception tree.
The base library ships platform-agnostic exceptions - AbstractBaseException and the Client/ and Server/ trees, aligned with HTTP 4xx/5xx semantics - and knows nothing about a transport, leaving every application to decide again how a thrown domain exception becomes a response.
This package decides it once. API requests get a uniform JSON body; web requests throwing a 422 land back on the originating form with errors and old input; every other package exception is answered on the web with the HTTP status it declares. An exception that does not extend AbstractBaseException falls through to Laravel's own handler, untouched.
ExceptionRenderer- the JSON strategy dispatcher, resolving an exception to the first strategy that supports it, with an always-matching fallback.WebRenderer- the web strategy dispatcher, resolving an exception to the first strategy that supports it, with an always-matching fallback.ExceptionsServiceProvider- auto-discovered, binding both dispatchers as singletons and registering tworenderable()hooks on Laravel's exception handler.- API detection - one shared rule across both hooks: the
Acceptheader, a JSON body, anapi/*path, or anapi.*route name. - Bundled strategies -
DefaultRendererandInputFailureRendererfor JSON,InputFailureWebRenderer,UnprocessableEntityWebRendererandDefaultWebRendererfor the web. - Custom strategies - resolve the singleton and prepend your own; the last registration wins.
Requirements
| Package | hradigital/php-exceptions-laravel |
|---|---|
| Namespace | HraDigital\Components\ExceptionRenderer |
| Requires | PHP ^8.2, hradigital/php-exceptions ^1.0 |
| Laravel | ^12.0 || ^13.0 |
| License | MPL-2.0 |
Laravel 13 itself requires PHP ^8.3, so PHP 8.2 is supported on the Laravel 12 line only.
Installation
composer require hradigital/php-exceptions-laravel
Registration
The service provider is auto-discovered — no manual registration needed in most apps. If auto-discovery is disabled for this package, register it explicitly:
bootstrap/providers.php:
return [ HraDigital\Components\ExceptionRenderer\ExceptionsServiceProvider::class, ];
What the provider does
On boot(), the provider:
- Binds
ExceptionRendererandWebRendereras singletons in the container (so app code can resolve them and add custom strategies). - Registers two
renderable()hooks on Laravel's exception handler — one for API requests (returnsJsonResponse), one for web requests (returnsResponse).
Both hooks are type-hinted AbstractBaseException, so Laravel dispatches them for every exception in this package and every application subclass of one, and for nothing else. No configuration, no publishing step and no application wiring is involved — the provider is auto-discovered through extra.laravel.providers.
API detection is shared between both hooks:
| Signal | Detected via |
|---|---|
Accept header negotiates JSON |
$request->expectsJson() / wantsJson() |
| Request body is JSON | $request->isJson() |
URL path matches api/* (or is exactly api) |
$request->is('api/*') |
Matched route name starts with api. |
$request->route()?->getName() |
- The JSON hook runs only for API requests; for web requests it returns
null. - The web hook runs only for non-API requests, and answers every
AbstractBaseException.UnprocessableEntityExceptionand itsRequestFailureExceptionspecialisation redirect back to the form; everything else is rendered byDefaultWebRendererwith the status the exception declares. An exception from outside this package never reaches the hook, so Laravel's default handler keeps rendering its usual HTML / Whoops / Blade error pages for those.
Response shapes
Default
Any AbstractBaseException rendered via DefaultRenderer:
{
"message": "Resource not found.",
"code": 404,
"data": { "id": 42 }
}
data is omitted when the exception was raised without structured payload (hasData() === false).
Input / validation failures
Exceptions implementing HraDigital\Components\Exceptions\Client\Request\RequestFailureInterface are rendered via InputFailureRenderer:
{
"message": "Invalid input.",
"code": 422,
"rules": { "email": ["required"] },
"failed": [
{ "fieldName": "email", "message": "email is required" }
]
}
rules mirrors getFailures() (the field-keyed rule list). failed is the flattened, per-message list derived from getFailedMessages() — one entry per {fieldName, message} pair, preserving field order.
Web responses
For non-API requests the WebRenderer runs its strategies in order, then falls back:
| Strategy | Matches | Response |
|---|---|---|
InputFailureWebRenderer |
RequestFailureInterface (structured field errors) |
back()->withErrors($exception->getFailedMessages())->withInput($input) |
UnprocessableEntityWebRenderer |
UnprocessableEntityException (and any subclass) |
back()->with('error', $exception->getMessage())->withInput($input) |
DefaultWebRenderer (fallback) |
every AbstractBaseException |
a response carrying the exception's own status - errors/{status}.blade.php when the application defines it, otherwise the exception message |
The fallback is what keeps a 401, 403, 404, 409 or 429 from rendering as a 500. Laravel reads no HTTP status off a DomainException, so without it any exception no strategy claimed reached the framework's generic handler and lost its status. An exception declaring no code, or a code outside 400-599, is answered 500.
This means:
- Forms throwing
RequestFailureException::withFailures([...], [...])get per-field errors flashed under the defaultMessageBag— Blade@error('field')/$errors->has('field')work out of the box. - Plain
UnprocessableEntityException('the field is invalid')flashes a singleerrorkey — render it in the layout via@if (session('error')) ... @endif. - Submitted input is always flashed via
withInput()soold('field')works in the form.
Adding a custom renderer strategy
Resolve the singleton and prepend a strategy. The first strategy whose supports() returns true wins; the bundled DefaultRenderer is the always-matching fallback.
use HraDigital\Components\ExceptionRenderer\ExceptionRenderer; use HraDigital\Components\ExceptionRenderer\Renderers\ExceptionRendererInterface; use HraDigital\Components\Exceptions\AbstractBaseException; use Symfony\Component\HttpFoundation\JsonResponse; final class TenantQuotaRenderer implements ExceptionRendererInterface { public function supports(AbstractBaseException $exception): bool { return $exception instanceof \App\Exceptions\TenantQuotaExceeded; } public function renderAsJson(AbstractBaseException $exception): JsonResponse { return new JsonResponse([ 'message' => $exception->getMessage(), 'code' => $exception->getCode(), 'tenant' => $exception->getData()['tenant'] ?? null, ], $exception->getCode()); } } // In a service provider's boot(): app(ExceptionRenderer::class)->add(new TenantQuotaRenderer());
add() always prepends, so later registrations override earlier ones.
Public API
| Class / interface | Purpose |
|---|---|
ExceptionRenderer |
JSON strategy dispatcher; renderAsJson(), add(), getStrategies(), factory. |
Renderers\ExceptionRendererInterface |
Contract every JSON strategy implements (supports() + renderAsJson()). |
Renderers\DefaultRenderer |
Always-matching JSON fallback; emits the default response shape. |
Renderers\InputFailureRenderer |
Matches RequestFailureInterface; emits validation-failure JSON shape. |
WebRenderer |
Web strategy dispatcher; renderAsRedirect(), add(), getStrategies(), getDefaultRenderer(), factory. Always returns a response. |
Renderers\WebRendererInterface |
Contract every web strategy implements (supports() + renderAsRedirect()). |
Renderers\DefaultWebRenderer |
Always-matching fallback; renders the status the exception declares. |
Renderers\InputFailureWebRenderer |
Matches RequestFailureInterface; redirects back with withErrors + withInput. |
Renderers\UnprocessableEntityWebRenderer |
Matches UnprocessableEntityException; redirects back with flash error + withInput. |
ExceptionsServiceProvider |
Singleton bindings + two Laravel renderable() hooks (JSON for API, redirect for web). |
Local development
composer install composer ci # lint + phpcs + phpstan + phpunit composer test # phpunit only composer cs # PSR-12 check composer cs:fix # PSR-12 autofix composer stan # phpstan (level 6)
A Makefile wraps the same tools with scoping and a silent-on-success mode — run make help for the full list:
make validate # syntax + phpcs + phpstan, run concurrently make validate-implementation # serial pre-merge pipeline, stops at the first failure make test # phpunit make lint # phpcs (report only) -- `make cs` is an alias make lint-fix # phpcbf autofix make analyse # phpstan (level 6) make syntax # php -l over src
Any target accepts QUIET=1, which suppresses all output on success (the test target prints only its final summary) and prints everything on failure. FILES="a.php b.php" scopes the file-based gates, FILTER=SomeTest narrows the test run, and EXEC="docker exec <name>" runs the PHP tools inside a container instead of natively.
Note that the Makefile follows the machine-wide target naming, which differs from the Composer scripts: make lint is PHPCS, whereas composer lint is the php -l syntax pass (make syntax).
Continuous Integration
.github/workflows/ci.yml runs on every push and PR to master. It executes lint → phpcs → phpstan → phpunit against the supported PHP × Laravel matrix:
| Laravel 12 | Laravel 13 | |
|---|---|---|
| 8.2 | ✓ | — |
| 8.3 | ✓ | ✓ |
| 8.4 | ✓ | ✓ |
| 8.5 | ✓ | ✓ |
Laravel 13 requires PHP ^8.3, so it has no 8.2 cell. Each Laravel line is paired with its own Testbench major — Laravel 12 with orchestra/testbench 10.*, Laravel 13 with 11.*.
The workflow pins illuminate/* and orchestra/testbench per matrix cell with composer require --no-update before installing.
Laravel 10 and 11 were dropped: both branches are past security support, so every laravel/framework release in them carries open security advisories and Composer refuses to install them.
Testing
The tests/ suite covers every class in the package:
ExceptionRendererTest— factory wiring, fallback path, strategy routing, prepend ordering, custom-constructor wiring.Renderers/DefaultRendererTest— status/message/code mapping, conditionaldatakey.Renderers/InputFailureRendererTest— flattening offailed,rulesmirroring, empty-payload behaviour.WebRendererTest— default strategy wiring, fallback toDefaultWebRenderer, prepend ordering for custom strategies.Renderers/DefaultWebRendererTest— the status per exception family, the 500 fallback for a non-error code, and the message body.Renderers/InputFailureWebRendererTest— back-with-errors flash + old-input restoration.Renderers/UnprocessableEntityWebRendererTest— back-with-error flash,withInput, subclass support.ExceptionsServiceProviderTest— Testbench-based: singleton bindings, end-to-end JSON rendering forapi/*requests, end-to-end redirect rendering forwebroutes throwingUnprocessableEntityException, end-to-end status rendering for awebroute throwing a 403, proof that an exception outside the package still renders through the framework, and direct callback invocation proving each hook returnsnulloutside its scope.Support/Stubs— shared anonymous-class factories for a generic exception and aRequestFailureInterfaceexception, excluded from the test suite.
License
Mozilla Public License 2.0 - see LICENSE. This matches the upstream
hradigital/php-exceptions license.
You may use this package in closed-source and commercial products. If you modify and distribute the package's own files, those files must remain under the MPL-2.0.
The HRADigital name and package names are not covered by that licence - see
TRADEMARK.md.