phpro/api-problem
A framework-agnostic RFC 7807 Problem Details implementation for PHP, providing an interface, an ApiProblemException, and built-in HTTP problem types (400–428) for consistent API error responses, with Symfony integration available.
phpro/api-problem is a framework-agnostic value-object library for RFC 7807 error payloads. It gives you ApiProblemInterface, a handful of ready-made HTTP problems, an ApiProblemException, and a debuggable variant. It ships no Laravel service provider — the Symfony bundle (phpro/api-problem-bundle) is a different package and must not be pulled into a Laravel app. You write the ~20 lines of glue that turn a problem into a JSON response.
composer require phpro/api-problem
Where to look first, in this order:
Phpro\ApiProblem\ApiProblemInterface — the whole contract is a single toArray(): array.Phpro\ApiProblem\Http\HttpApiProblem — the base class that fills in type and title from an HTTP status code.Phpro\ApiProblem\Exception\ApiProblemException — the throwable you will actually throw from controllers/services.Phpro\ApiProblem\Http\* (400, 401, 403, 404, 405, 409, 412, 415, 418, 422, 423, 428).First use case — throw from a controller and render it in the exception handler (Laravel 11+ bootstrap/app.php):
use Phpro\ApiProblem\Exception\ApiProblemException;
use Phpro\ApiProblem\Http\NotFoundProblem;
public function show(int $id)
{
$book = Book::find($id);
if (! $book) {
throw new ApiProblemException(new NotFoundProblem("Book {$id} could not be found."));
}
return $book;
}
// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (ApiProblemException $e, Request $request) {
if (! $request->expectsJson()) {
return null; // fall through to Laravel's default handling
}
$problem = $e->getApiProblem();
$payload = $problem->toArray();
$status = (int) ($payload['status'] ?? 500);
return response()
->json($payload, $status)
->header('Content-Type', 'application/problem+json');
});
})
That single renderer is the day-to-day entry point. Everything else is choosing which problem object to throw.
1. A status→problem factory instead of new scattered everywhere. You will otherwise forget the class name for 409. Keep a small map and a domain-level helper:
final class Problems
{
public static function notFound(string $detail): ApiProblemException
{
return new ApiProblemException(new NotFoundProblem($detail));
}
}
2. Map framework exceptions to problems in one place. Route-model-binding failures, validation, auth, and 429 all arrive as Laravel exceptions, not problem objects. Normalise them at the edge so the renderer only ever sees ApiProblemException:
$exceptions->render(fn (ModelNotFoundException $e, Request $r) =>
$r->expectsJson() ? self::renderProblem(new NotFoundProblem('Resource not found.')) : null);
Or convert inside renderable callbacks and let the ApiProblemException renderer handle formatting. Either way, one formatting path.
3. Wrap the generic exception renderer. A production-safe default for anything unexpected:
$exceptions->render(function (Throwable $e, Request $request) {
if (! $request->expectsJson() || $e instanceof ApiProblemException) {
return null;
}
return $e instanceof HttpExceptionInterface
? self::renderProblem(new HttpApiProblem($e->getStatusCode(), ['detail' => $e->getMessage()]))
: self::renderProblem(new HttpApiProblem(500, ['detail' => 'An unexpected error occurred.']));
});
4. Gate the debug payload on config('app.debug'). ExceptionApiProblem and DebuggableApiProblemInterface::toDebuggableArray() exist for local/staging. In the renderer:
$payload = $problem instanceof DebuggableApiProblemInterface && config('app.debug')
? $problem->toDebuggableArray()
: $problem->toArray();
ExceptionApiProblem(new \Exception('...', 500)) is the fastest way to get stack traces into your API responses during development — just make sure it is impossible to reach in production.
5. Map Laravel validation to a problem, not to Symfony's validator. ValidationApiProblem requires symfony/validator and a ConstraintViolationList; installing that into a Laravel app to satisfy one class is not worth it. Build an equivalent problem yourself (or a thin HttpApiProblem subclass) that emits the same shape from Laravel's MessageBag:
final class ValidationProblem implements ApiProblemInterface
{
public function __construct(private MessageBag $errors) {}
public function toArray(): array
{
return [
'status' => 422,
'type' => 'https://example.com/errors/validation',
'title' => 'Validation Failed',
'detail' => $this->errors->first(),
'violations' => collect($this->errors->keys())->map(fn ($field) => [
'propertyPath' => $field,
'title' => $this->errors->first($field),
])->all(),
];
}
}
Then render ValidationException through it. Do this behind a feature flag or a new API version — it changes the errors envelope your existing clients parse.
6. Use stable type URIs for domain errors. HttpApiProblem derives type from the RFC 2616 status page, which is fine for transport errors but useless for machine-readable domain errors (e.g. "insufficient credit"). Implement ApiProblemInterface directly for those, or pass type in the additional-data array and confirm it wins the merge in HttpApiProblem::toArray() — if it does not, subclass and override toArray(). Point type at a docs URL; that link is the entire value proposition of RFC 7807.
7. Keep the renderer's HTTP status and the body's status in sync. They are two independent values and will drift. Always derive the response code from the payload ((int) $payload['status']), never hardcode 422 in the renderer.
8. Log 5xx with context, stay quiet on 4xx. A problem object is a response, not a log entry. In the renderer, report($e) only when status >= 500, and log the ApiProblemException's previous exception rather than the wrapper.
9. Document and test the contract. Add a Problem schema to your OpenAPI spec once and reference it from every error response. In feature tests, assert both halves:
$response->assertStatus(404)
->assertHeader('Content-Type', 'application/problem+json')
->assertJsonPath('type', 'https://example.com/errors/not-found')
->assertJsonMissingPath('exception'); // guard against trace leakage
That last assertion is the cheapest regression test you will ever write.
ApiProblemException lives at Phpro\ApiProblem\Exception\ApiProblemException. The README's use Phpro\ApiProblem\Exception; snippet is shorthand for the namespace; importing the namespace and then referencing ApiProblemException unqualified works, but copy-pasting the fully-qualified name is less confusing in a Laravel codebase.application/problem+json and the response status are entirely your renderer's job. If you use response()->json(...) without the header, you silently serve application/json and lose client-side content negotiation.APP_DEBUG. If you always call toDebuggableArray(), or you throw raw ExceptionApiProblem in production, you will ship file paths, line numbers, and stack traces to the internet. Prefer HttpApiProblem/specific problem classes in application code and reserve ExceptionApiProblem for the debug branch of the renderer.ExceptionApiProblem falls back to 500 when the exception code is not an integer. Do not assume new \Exception('x', 'PAYMENT_FAILED') gives you anything meaningful, and never use exception codes as your public error code — use the type URI.NotFoundProblem returns "Not found" while MethodNotAllowedProblem returns "Method Not Allowed". Assert on status and type, not title, and definitely do not build UI copy on it.ThrottleRequestsException) needs a HttpApiProblem(429, [...]) of your own — and RFC 7807 pairs it well with a Retry-After header, which you must set manually.withExceptions. Registration is last-in-first-wins for render callbacks in practice; register your catch-all Throwable renderer before the specific ApiProblemException one, and always return null from handlers that do not apply so Laravel falls through to the next.$request->expectsJson() (or an Accept check). Without it, browser requests hit your problem renderer and users get raw JSON instead of Laravel's HTML error page or auth redirect.ApiProblemInterface::toArray() does not guarantee a status key. The interface is deliberately loose ("the RFC is very loose"), so custom problems can omit it. Always default to 500 when reading it for the HTTP status.HttpApiProblem passes your additional-data array straight through. Put violations, retryAfter, traceId, docs there rather than inventing a wrapper envelope — that is what keeps the format interoperable.response()->json([...], 404).Problems factory and ApiProblemInterface-shaped arrays, and the package can be replaced in an afternoon if it goes unmaintained. Do not let ApiProblemInterface leak into dozens of domain classes.How can I help you explore Laravel packages today?