crell/api-problem
A framework-agnostic PHP library implementing RFC 9457 (formerly RFC 7807) Problem Details for HTTP APIs. It provides structured problem objects, exceptions, and response helpers for consistent, machine-readable API error responses.
composer require crell/api-problem
There is nothing to publish, no service provider, no config file. It's a single small class (Crell\ApiProblem\ApiProblem) that models an RFC 7807 "problem details" document and knows how to serialize itself to JSON. Read src/ApiProblem.php first — it's short enough to read end-to-end in five minutes, and that will tell you more than the README.
The first use case is almost always "my API returns a bare {"message": "Not found"} and clients can't tell errors apart." Build the problem in the controller and return it with the correct media type:
use Crell\ApiProblem\ApiProblem;
public function show(string $id)
{
$order = Order::find($id);
if (! $order) {
$problem = (new ApiProblem())
->setType('https://api.example.com/problems/order-not-found')
->setTitle('Order not found')
->setStatus(404)
->setDetail("No order exists with id {$id}.")
->setInstance(request()->fullUrl());
return response((string) $problem, 404)
->header('Content-Type', 'application/problem+json');
}
return $order;
}
(string) $problem yields the JSON body; json_encode($problem) works too because the class implements JsonSerializable, so response()->json($problem, 404)->header('Content-Type', 'application/problem+json') is equally valid and slightly more Laravel-idiomatic.
Centralize construction. Don't sprinkle new ApiProblem() through controllers. Create a small factory (or a handful of named subclasses) so type URIs, titles, and extension members stay consistent:
final class RateLimitedProblem extends ApiProblem
{
public function __construct(int $retryAfter)
{
parent::__construct();
$this->setType('https://api.example.com/problems/rate-limited')
->setTitle('Rate limit exceeded')
->setStatus(429)
->setDetail('Too many requests. Try again shortly.');
$this['retry_after'] = $retryAfter;
}
}
Subclassing is the intended extension point: the class extends ArrayObject, so $problem['retry_after'] = ... adds arbitrary extension members to the payload for free.
Wire it into the exception handler. In Laravel 11's bootstrap/app.php, map domain exceptions to problems so every API error has one shape:
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (OrderNotFoundException $e, Request $request) {
if (! $request->is('api/*')) {
return null; // fall through to the default HTML/JSON handler
}
$problem = (new ApiProblem())
->setType('https://api.example.com/problems/order-not-found')
->setTitle('Order not found')
->setStatus(404)
->setDetail($e->getMessage())
->setInstance($request->fullUrl());
$problem['trace_id'] = $request->header('X-Request-Id');
return response((string) $problem, 404)
->header('Content-Type', 'application/problem+json');
});
})
Do the same for ValidationException. Put $e->errors() into an extension member ($problem['errors'] = $e->errors();) with status 422. This is the single highest-value integration: it's where clients spend most of their error-handling code, and it keeps the standard fields (title, detail) free for humans.
Use instance for correlation, not navigation. The spec calls it "a URI reference that identifies the specific occurrence." A trace ID (urn:uuid:...) or the full request URL plus a log correlation key is more useful than the route path alone.
Gate on content negotiation, not route prefix. If a client sends Accept: application/problem+json, honour it; a shouldRenderJsonWhen rule or a small middleware is a cleaner switch than $request->is('api/*') scattered everywhere.
status field is advisory. Laravel will happily return HTTP 200 with "status": 404 in the body if you forget the second argument. Set both, and consider a test asserting they agree.Content-Type: application/problem+json is not optional. response()->json() sets application/json; overwrite it. Generic RFC 7807 clients key off the media type and will ignore a application/json payload.type defaults to about:blank. Per the spec, that means "no semantics beyond the HTTP status" — and in that case title should just be the status reason phrase. Don't ship about:blank and a rich custom detail if you expect machine handling; mint a real type URI instead.detail. It's rendered to end users. Stack traces, SQL, and model IDs belong in logs, not here.$this. If you bind a pre-built "template" problem in the container or a static property and call setDetail() on it, you'll leak state between requests — especially under Octane. Always construct fresh (or clone a prototype) per response.offsetSet; don't collide with type, title, status, detail, or instance unless you're deliberately overriding. Nested arrays serialize fine; objects need to be JsonSerializable.json_encode($problem) in tinker and inspect — the ArrayObject storage plus the protected standard fields can surprise you. json_last_error() after encoding is your friend if a stray resource or closure slipped into an extension member.make:problem command, no config. That's fine — the class is small enough to fork and vendor if you need a change — but budget an afternoon for the factory, handler wiring, and tests rather than expecting composer require to be the whole job.assertStatus, assertHeader('Content-Type', 'application/problem+json'), and assertJsonPath('type', ...) give you far more safety than unit-testing ApiProblem itself.How can I help you explore Laravel packages today?