Skip to main content
WritingVideosSpeakingCodeAbout

API Design

Most of Your API Has Already Been Designed

Error formats, retries, lost updates, webhook signing: the IETF has already answered them. api-toolbox brings those answers to Laravel, one RFC per feature.

Oct 202617 min read

Most Laravel APIs design their own error format. Some return {"message": "..."} because that is what Laravel does. Some wrap it in an error object. Some add a code. Validation errors come back in a different shape again, because that is also what Laravel does. Then a client team writes a parser for it, and somebody writes the documentation page that explains it, and the next API in the same company does it slightly differently.

None of that is necessary. There is a standard for API errors, RFC 9457, and it has existed in one form or another since 2016. There is a standard for safely retrying a POST. There is one for stopping two clients overwriting each other’s changes, one for telling clients an endpoint is going away, one for signing webhooks, and one for telling a client what your rate limit is before it hits it.

Most of what we design by hand in an API has already been designed, argued over, and written down by people who spent years on exactly that problem. We just do not use it, mostly because using it in Laravel means reading the RFC and writing the middleware yourself.

So I wrote api-toolbox: one Laravel package where every feature implements a published HTTP standard. This article is the tour. What each feature is, why you would want it, and just as importantly, when you would not.

Why standards, and not just good conventions

You might be thinking that a consistent error format of your own is fine, as long as it is consistent. It is fine. It is also a cost you pay forever, in three places.

Your clients pay it. Every consumer of your API writes code for your error shape, your retry header, your pagination envelope. With a standard, they reach for a library that already understands it. An HTTP client that knows Retry-After does not need to know about you.

Your documentation pays it. A home-grown idempotency scheme needs a page explaining it. “This endpoint supports the Idempotency-Key header” needs a link.

Your future self pays it. Every decision you make by hand is one you have to defend, revisit and keep consistent. A standard has already made those decisions, and usually made them better, because the people writing it had to think about proxies, caches and clients you have never heard of.

There is a fourth consumer now, too. An AI agent calling your API has seen application/problem+json and Retry-After thousands of times. It has never seen your bespoke error envelope. The more of your API that matches something written down, the less of it a machine has to guess about.

Installing it

Terminal window
composer require juststeveking/api-toolbox
php artisan vendor:publish --tag=api-toolbox-config

Features are switched on in config/api-toolbox.php, the same way Laravel Fortify does it:

use JustSteveKing\Toolbox\Features;
'features' => [
Features::problemDetails(['type_base_uri' => 'https://docs.example.com/problems']),
Features::deprecation(),
Features::conditionalRequests(),
Features::pagination(),
Features::idempotency(['ttl' => 86400]),
Features::negotiation(),
Features::preferences(),
Features::asyncOperations(),
Features::mergePatch(),
Features::digests(),
Features::messageSignatures(['keys' => [/* ... */]]),
Features::apiCatalog(['apis' => [/* ... */]]),
],

There is one difference from Fortify that matters. Fortify registers everything and gates its routes. Here, a feature you leave out registers nothing at all: no middleware alias, no container binding, no exception handling hook, no test assertion. If you only want Problem Details, you get Problem Details and nothing else touches your application.

If you never publish the config, every stable feature is on with sensible defaults. Want to see what is actually enabled? php artisan about has a section for it.

Terminal window
$ php artisan about --only=api_toolbox
API Toolbox ................................................................
API Catalog ........................................................ ENABLED
Async Operations ................................................... ENABLED
Conditional Requests ............................................... ENABLED
...
RateLimit Headers ................................... ENABLED (experimental)

The rest of this article walks through building an orders API with it, one problem at a time.

Errors: Problem Details

What: RFC 9457 gives every error a type URI that identifies it, a fixed set of members, and its own media type, application/problem+json.

Why: a client can tell one error from another without matching message strings, and the type URI can point at a page explaining what went wrong and how to recover.

When: always. If you turn on one feature, make it this one.

The first thing you will notice is that you do not have to do anything. With the feature on, abort(404), a failed $request->validate(), a failed authorization check and an uncaught exception all come back as problems:

{
"type": "https://apiguide.dev/errors/resource-not-found",
"title": "Resource Not Found",
"status": 404,
"instance": "urn:uuid:0199e7f4-5d2a-7c3e-9b1a-4f2d8e6c1a07"
}

Two things in there are worth pointing out. The type resolves to a real page in the apiguide.dev error catalogue, so a developer who gets this error can follow the link and read what it means. And instance is the request ID, so an error a customer reports leads straight to its log lines.

For errors your own domain defines, there is an Artisan command:

Terminal window
php artisan make:api-problem OutOfCredit --title="You do not have enough credit." --status=403
use JustSteveKing\Toolbox\ProblemDetails\Attributes\ProblemType;
use JustSteveKing\Toolbox\ProblemDetails\ProblemException;
#[ProblemType('out-of-credit', title: 'You do not have enough credit.', status: 403)]
final class OutOfCredit extends ProblemException {}

Then you throw it with the details of this particular occurrence:

throw new OutOfCredit(
detail: 'Your current balance is 30, but that order costs 50.',
extensions: ['balance' => 30],
);

Notice what lives where. The type, title and status are on the class, because the RFC says a title should not change from one occurrence to the next. The detail and the extensions belong to this request. That split is the whole design of the standard, and putting the fixed parts on the class makes it hard to get wrong.

Validation errors keep Laravel’s messages, keyed by field, under an errors member, which is what apiguide.dev documents for its validation-failed type. Your front end does not need rewriting.

When not: if your API speaks JSON:API, which has its own error format. The package detects application/vnd.api+json and leaves those requests alone.

Lost updates: conditional requests

What: entity tags and preconditions, from RFC 9110 section 13.

Why: two people open the same order, both edit it, and the second save silently overwrites the first. Nobody gets an error. Somebody’s change is just gone.

When: on any resource more than one client can change.

The fix is that a client says which version it read, and the server refuses the write if that is no longer the current version. HTTP has done this since the nineties, with ETag and If-Match.

First, the model needs to be able to say what version it is:

use JustSteveKing\Toolbox\ConditionalRequests\Concerns\HasModelValidators;
use JustSteveKing\Toolbox\ConditionalRequests\Contracts\HasValidators;
final class Order extends Model implements HasValidators
{
use HasModelValidators;
}

Then the routes opt in:

Route::middleware('conditional')->group(function () {
Route::get('orders/{order}', ShowOrder::class);
Route::patch('orders/{order}', UpdateOrder::class)->middleware('preconditions.required');
});

Now a GET returns an ETag. A PATCH with an If-Match that no longer matches gets a 412, carrying the current ETag so the client knows what to fetch again. A PATCH with no precondition at all gets a 428, because preconditions.required makes sending one mandatory. And a repeat GET with If-None-Match gets a bodiless 304, which is a nice side effect for clients that cache.

All of this happens before your controller runs. You do not compare timestamps in your action, and you do not add a version field to your request body.

One honest caveat. HasModelValidators builds its tag from updated_at, and MySQL’s default timestamp columns store whole seconds. Two writes in the same second would share a tag. If you need real optimistic concurrency, add an integer version column that every write increments and return it from entityTagVersion().

Partial updates: JSON Merge Patch

What: RFC 7396, a precise meaning for a PATCH body.

Why: most Laravel APIs treat PATCH as a partial PUT. Send the fields you want to change, and hope nobody ever needs to clear one. Merge Patch has exact rules: a member present changes, a member set to null is removed, and a member absent is left alone, all the way down nested objects.

When: on PATCH endpoints for JSON resources, which is most of them.

use Illuminate\Support\Facades\Validator;
use JustSteveKing\Toolbox\MergePatch\MergePatch;
final class UpdateOrder
{
public function __invoke(Order $order, MergePatch $patch): OrderResource
{
$fields = ['reference', 'notes', 'shipping'];
$document = $patch->applyTo($order->only($fields));
Validator::make($document, [
'reference' => ['required', 'string'],
'notes' => ['nullable', 'string'],
'shipping' => ['array'],
])->validate();
$order->update($patch->changesTo($order->only($fields)));
return new OrderResource($order);
}
}

The important line is the validation. You validate the merged document, not the patch. In a patch, null means “remove this”, so rules written for the resource would misread it, and every required rule would fail on fields the client simply left out. What has to be valid is the result.

Add the merge-patch middleware to the route and a client sending any other PATCH format gets a 415 with Accept-Patch: application/merge-patch+json, which tells it what to send instead.

Retries: Idempotency-Key

What: the Idempotency-Key header, from an IETF draft that has kept the same shape through every revision and is widely used.

Why: a client posts a payment, the connection drops, and it cannot tell whether the charge went through. Retrying might charge twice. Not retrying might charge never.

When: on any POST where doing it twice would hurt. Payments, orders, anything that sends an email.

Route::post('orders', CreateOrder::class)->middleware('idempotent');
Route::post('payments', CreatePayment::class)->middleware('idempotent:required');

A retry with the same key and the same payload gets the original response back, without the action running again. The same key with a different payload is a 422, because that is a client bug, not a retry. A retry that arrives while the first request is still running gets a 409 with Retry-After.

Keys are scoped to the authenticated user, so two clients cannot collide. Failed validation is stored and replayed, but a 5xx is not, because a request that failed on the server is exactly the one a client should be able to retry.

When not: with a cache store that cannot do atomic locks. That is how a concurrent retry is told apart from a finished one, so the package refuses APC, session and null stores rather than quietly failing open. Redis, Memcached, the database and the file store are all fine.

Slow work: Prefer and 202 Accepted

What: RFC 7240’s Prefer header, and a 202 Accepted that points at a status monitor.

Why: APIs grow query parameters like ?return=none and ?async=1 to let clients ask for a lighter response or background processing. Prefer is the header that already exists for exactly that.

When: on writes where a client might not want the body back, and on anything that takes longer than a request should.

Add the prefer middleware and a client sending Prefer: return=minimal gets an empty response to its write: 200 becomes 204, and a 201 keeps its Location. Your controller does not change.

For slow work, a client can send Prefer: respond-async, and you hand the job to the queue:

use JustSteveKing\Toolbox\AsyncOperations\Operations;
use JustSteveKing\Toolbox\Preferences\Preferences;
public function store(StoreReportRequest $request, Operations $operations, Preferences $preferences)
{
if ($preferences->respondAsync()) {
return $operations->dispatch(new GenerateReport($request->validated()));
}
return new ReportResource((new GenerateReport($request->validated()))->handle());
}

The client gets a 202 with a Location pointing at a status monitor. Polling it returns the job’s status while it runs, and a 303 See Other to the result once it is done. The job only has to say where that result lives:

use JustSteveKing\Toolbox\AsyncOperations\CurrentOperation;
public function handle(CurrentOperation $operation): void
{
$report = Report::create(/* ... */);
$operation->resultAt(route('reports.show', $report));
}

I want to be upfront about this one: the standards stop at the 202. RFC 9110 says a 202 ought to point at a status monitor and stops there. The monitor is this package’s convention, built from standard parts, and the README says so.

Webhooks: HTTP Message Signatures

What: RFC 9421, the IETF’s single answer to “how do I prove who sent this request”.

Why: every webhook provider signs its requests its own way. An HMAC in a header with a name of its choosing, over bytes of its choosing, sometimes with a timestamp. Every receiver writes code for each one.

When: sending webhooks, receiving them, or any server-to-server call where you need to know the caller and know the body was not changed.

I used to maintain two webhook packages of my own, laravel-webhooks and webhooks. Both signed requests with an HMAC in a Signature header I designed. Both are archived now, because this replaces them with the standard, and any receiver that speaks RFC 9421 can verify what it sends.

Keys are configured by ID:

Features::messageSignatures([
'keys' => [
'webhooks' => ['algorithm' => 'hmac-sha256', 'secret' => env('WEBHOOK_SECRET')],
'partner' => ['algorithm' => 'ed25519', 'public_key' => storage_path('keys/partner.pem')],
],
]),

Sending a signed webhook is one method on Laravel’s HTTP client:

Http::withSignature('webhooks')->post($endpoint, $payload);

That signs the method, the target URI, the Content-Type and a Content-Digest of the body, and adds the digest for you. Put it in a queued job with $tries and $backoff, and Laravel handles delivery and retries.

Receiving is middleware:

Route::post('webhooks/orders', HandleOrderWebhook::class)->middleware('signature.verify');

A request passes when one of its signatures comes from a known key, covers what you require, matches, matches the body, and is recent. Anything else is a 401 problem carrying an Accept-Signature header that tells the sender what to sign next time.

This is the feature I tested hardest, because signing code that is nearly right is worse than none. The signature base is checked byte for byte against every test case in the RFC’s Appendix B, and the Structured Fields parser underneath it passes all 2,142 cases in the HTTP working group’s own test suite.

Telling clients things: deprecation, catalog, negotiation

A few smaller features do one job each.

Deprecation and Sunset (RFC 9745 and RFC 8594) put a changelog entry where every client will see it: in the response.

use JustSteveKing\Toolbox\Deprecation\Attributes\Deprecation;
use JustSteveKing\Toolbox\Deprecation\Attributes\Sunset;
#[Deprecation(at: '2026-06-30T23:59:59Z', link: 'https://docs.example.com/deprecations/orders-v1')]
#[Sunset(at: '2027-01-01T00:00:00Z')]
final class OrderController
{
// ...
}

Every response from that controller now carries Deprecation and Sunset headers that client libraries and gateways can act on. The attributes are not called #[Deprecated] on purpose: PHP 8.4’s native #[\Deprecated] raises a deprecation notice every time the method is called, which on a controller means on every request.

The API catalog (RFC 9727) publishes /.well-known/api-catalog, one predictable place listing your APIs and where their OpenAPI descriptions and documentation live. It is the first thing an agent or crawler that knows nothing else about you can look for.

Negotiation gives you produces and consumes middleware for clean 406 and 415 responses, joins every request to its W3C trace, and gives every request an ID that appears in your logs and in every problem’s instance. Request IDs are the one part of the package with no standard behind them. They are in because a request ID is how a support ticket, a log line and a response get tied together.

Testing it

Every feature comes with assertions on Laravel’s TestResponse, and matching Pest expectations. They check the exact syntax each standard specifies, not just that a header is present.

it('refuses a stale update', function () {
$order = Order::factory()->create();
$etag = $this->getJson("/api/orders/{$order->id}")->assertETag()->headers->get('ETag');
$order->touch();
$this->patchJson("/api/orders/{$order->id}", ['notes' => 'Leave at the door'], ['If-Match' => $etag])
->assertPreconditionFailed();
});
it('does not charge twice', function () {
$payload = ['order_id' => Order::factory()->create()->id, 'amount' => 5000];
$first = $this->postJson('/api/payments', $payload, ['Idempotency-Key' => '"abc"']);
$retry = $this->postJson('/api/payments', $payload, ['Idempotency-Key' => '"abc"']);
$retry->assertIdempotentReplay($first);
expect(Payment::count())->toBe(1);
});

assertProblemType(), assertDeprecated(), assertLinkHeader() and the rest work the same way. They are only registered while your tests run, and only for features you have enabled.

When to use what

If you only read one section, make it this one.

You have this problemUse thisStandard
Clients cannot tell errors apartProblem DetailsRFC 9457
Two clients overwrite each otherconditional and preconditions.requiredRFC 9110 §13
PATCH cannot clear a fieldmerge-patchRFC 7396
A retried POST might run twiceidempotentIdempotency-Key draft
Clients want less back, or background workprefer, Operations::dispatch()RFC 7240
You send or receive webhookswithSignature(), signature.verifyRFC 9421
Bodies get corrupted between hopsdigest.verify, digest.respondRFC 9530
An endpoint is going away#[Deprecation], #[Sunset]RFC 9745, RFC 8594
Nobody can find your APIsFeatures::apiCatalog()RFC 9727
Clients need navigation, not envelopesCursorPageRFC 8288

And when not to reach for it at all: if you are building with JSON:API, which already answers most of these its own way. If you need JSON Patch (RFC 6902) rather than Merge Patch, which the package does not do. If you throttle with throttleWithRedis() and want RateLimit headers, which is a documented gap. And treat RateLimit headers as experimental in general, because the draft underneath them is still moving. The package is at 0.x for the same reason most packages are: it needs to be used in real applications before it promises not to change.

Laravel Boost knows how to use it

A lot of the code that uses this package will not be typed by a person. It will be written by an agent, in Claude Code or Cursor or whatever comes next, and an agent that knows nothing about the package writes an API error the way most Laravel code does: response()->json(['message' => ...], 422). Nothing about that is wrong, and it throws away everything the package gives you.

So the package ships guidance for Laravel Boost. Install Boost, run one command, and it is picked up:

Terminal window
composer require laravel/boost --dev
php artisan boost:install

There are two parts to it.

A guideline, which is loaded into every agent session. It is short on purpose: check which features are enabled before using one, throw problems instead of returning error JSON, generate problem types with make:api-problem, and reach for the standard when one exists. Optimistic locking means conditional and a validator on the model, not a version field in the body. A retried POST means idempotent, not a dedupe table. A webhook means withSignature(), not a hand-rolled HMAC.

A skill, called api-toolbox-development, which an agent only loads when it is working on an endpoint. It has a reference file for each area: errors, caching and pagination, writes, signatures and digests, the API lifecycle, and testing. That split matters. An agent fixing a webhook does not need the pagination rules in its context, and loading everything up front is how you get an agent that has read everything and remembers none of it.

Guidance like this has one failure mode that matters more than the rest, which is going stale. An agent following a code sample that names a class you renamed two releases ago is worse off than one with no guidance at all, because it is confidently wrong. So the package’s test suite reads the guideline and the skill, and fails if any class, constant, middleware alias, assertion or method they mention does not exist, or if any PHP sample in them does not parse. The webhook testing recipe in the skill runs as a test of its own. If I change the API and forget the guidance, CI tells me.

When the package updates, php artisan boost:update brings the guidance with it.

What this means for how you build APIs

The thing I want you to take from this is not “install my package”, although please do. It is that a lot of the decisions in your API have already been made, by people who thought about them harder than any of us has time to on a feature deadline.

Your error format, your retry semantics, your concurrency control, your webhook signatures and your deprecation notices are not where your API should be different from everyone else’s. Your domain is. The less time you spend designing the parts that are already designed, the more you have for the part that is actually yours.

api-toolbox is on GitHub and on Packagist. Every feature’s README section quotes the part of the RFC it implements, and explains every place where the spec was ambiguous and which way the package went. If you find a place where it gets the standard wrong, that is a bug, and I would like to hear about it.

Related

More in API Design

All posts →

Same subject

In other formats

All of API Design →