Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Api Problem Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. Phpro\ApiProblem\ApiProblemInterface — the whole contract is a single toArray(): array.
  2. Phpro\ApiProblem\Http\HttpApiProblem — the base class that fills in type and title from an HTTP status code.
  3. Phpro\ApiProblem\Exception\ApiProblemException — the throwable you will actually throw from controllers/services.
  4. The built-in problem classes in 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.

Implementation Patterns

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.

Gotchas and Tips

  • No Laravel integration means no auto-discovery. Do not expect a service provider, config file, or published assets. The Symfony bundle is unrelated and will not help you.
  • 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.
  • The package never sets HTTP headers or status codes. 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.
  • Debug output is opt-in — and that is a footgun. Nothing in the package knows about 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.
  • Non-numeric exception codes become 500. Since v1.4.1 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.
  • Titles are inconsistent and not a stable API. 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.
  • Statuses 429, 500, 502, 503 are not built in. You get 400/401/403/404/405/409/412/415/418/422/423/428. Rate limiting (ThrottleRequestsException) needs a HttpApiProblem(429, [...]) of your own — and RFC 7807 pairs it well with a Retry-After header, which you must set manually.
  • Ordering matters in Laravel's 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.
  • Web vs API. Guard every renderer with $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.
  • Extension members are the intended extension point. RFC 7807 permits extra keys, and 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.
  • Everything is an immutable-ish value object with no dependencies or state. It is safe under Octane, in queued jobs (to serialise an error context), and in domain layers. That portability is the main reason to use it rather than hand-rolling response()->json([...], 404).
  • Maturity risk is low but real: 66 stars, zero dependents, maintainer-driven. The API surface is tiny and stable, which makes the escape hatch cheap: depend on your own 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.
  • PHP 8.2–8.5 support in current releases lines up with Laravel 10/11/12; if you are pinned to PHP 8.0/8.1, stay on the matching older tag (1.5.x/1.7.x).
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
dakiquang/laravel-oauth-refresh-lock
woda/laravel-worktrees
dalefranks/ramasite
faghy/laracashbook
roundly-consulting/google-places-for-laravel
roundly-consulting/kubernetes-api-for-laravel
roundly-consulting/plausible-for-laravel
gvp/gvp-laravel-template
metasyncsite/laravel-metasync-client
roundly-consulting/onboarding-for-laravel
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
adachsoft/changelog-linter