juststeveking / api-toolbox
Standards-compliant APIs in Laravel, one RFC at a time.
Requires
- php: ^8.3
- laravel/framework: ^13.30
Requires (Dev)
- larastan/larastan: ^3.13
- laravel/pint: ^1.32
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.1 || ^5.0
- pestphp/pest-plugin-laravel: ^4.0 || ^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Standards-compliant APIs in Laravel, one RFC at a time.
Each feature in this package implements a published HTTP standard, and each one is switched on or off by itself. A feature you leave off registers nothing: no bindings, no middleware aliases, no macros, no exception handling hooks.
Requires PHP 8.3 and Laravel 13.30 or later.
| Feature | Standard | Status |
|---|---|---|
| Problem Details | RFC 9457 | Stable |
| Deprecation and Sunset | RFC 9745, RFC 8594 | Stable |
| Conditional Requests | RFC 9110 §13, RFC 6585 §3 | Stable |
| Pagination | RFC 8288 | Stable |
| Idempotency | draft-ietf-httpapi-idempotency-key-header-07 | Stable; the draft has expired |
| RateLimit headers | draft-ietf-httpapi-ratelimit-headers-11 | Experimental |
| Preferences | RFC 7240 | Stable |
| Async operations | RFC 9110 §15.3.3, RFC 7240 §4.1; the status monitor is this package's own | Stable |
| Merge Patch | RFC 7396 | Stable |
| Digest Fields | RFC 9530; problem types from draft-ietf-httpapi-digest-fields-problem-types-06 | Stable |
| Message Signatures | RFC 9421 | Stable |
| API Catalog | RFC 9727, RFC 9264, RFC 8631 | Stable |
| Negotiation | RFC 9110 §12, W3C Trace Context; request IDs have no standard | Stable |
| Testing | Assertions for each of the above | Stable |
One part of the package has no standard behind it: request IDs, in Negotiation. No specification defines a request ID header. They are included because a request ID is how a support ticket, a log line and a response get tied together, and the Negotiation section says exactly what they do.
Every problem the package raises itself is typed from the
apiguide.dev error catalogue, so each type resolves to a page
explaining the error and how to recover.
Install
composer require juststeveking/api-toolbox php artisan vendor:publish --tag=api-toolbox-config
Features are listed in config/api-toolbox.php. Options are passed to each one inline:
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' => [/* ... */]]), // Features::rateLimitHeaders(), ],
If you never publish the config, every stable feature is on with its defaults. A feature name the package does not recognise throws when the application boots, so a typo cannot silently switch something off. Every option is listed in the configuration reference.
To check a feature at runtime:
use JustSteveKing\Toolbox\Facades\ApiToolbox; use JustSteveKing\Toolbox\Features; ApiToolbox::enabled(Features::idempotency());
Or inject JustSteveKing\Toolbox\Contracts\EnabledFeatures.
php artisan about has an API Toolbox section listing the installed version, each feature as
enabled or off, and the configured signature key IDs with their algorithms, never their secrets
or paths. php artisan about --only=api_toolbox shows just that section. In --json output it is
keyed a_p_i_toolbox, because Laravel snake-cases section names.
The config follows Laravel Fortify's Features design, and works with php artisan config:cache
for the same reason Fortify's does.
AI agents
The package ships a Laravel Boost guideline and skill, so a coding agent working in your application uses these features instead of hand-building error JSON, custom headers or HMAC checks. With Boost installed, run:
php artisan boost:install # or boost:update, if Boost is already set up
The guideline (resources/boost/guidelines/core.blade.php) is always loaded and short: check
which features are enabled, throw problems rather than returning error JSON, and which standard
to reach for. The api-toolbox-development skill is loaded when an agent works on an endpoint,
with a reference per area: errors, caching and pagination, writes, signatures and digests, API
lifecycle, and testing. A test checks that every class, constant, middleware alias, assertion
and method they mention exists, and that every PHP snippet parses.
Stability
A feature built on an IETF draft that is still moving is marked experimental, here and in the
docblock of its Features:: method. Experimental features can change in a breaking way in a
minor release, because the draft underneath them can. Pin a minor version if you rely on one.
Every other feature follows semantic versioning.
Idempotency is built on a draft too, but one that expired without changing shape, and the header is in wide use. It is treated as stable.
Problem Details
Laravel returns errors as {"message": "..."}, with a different shape again for validation.
A client has no stable way to tell one error from another, other than matching message
strings. RFC 9457 gives every error a type URI that identifies it, a fixed set of members, and
a media type, application/problem+json.
Features::problemDetails([ 'type_base_uri' => 'https://docs.example.com/problems', // default: /problems 'paths' => ['api/*'], // default: ['api/*'] 'apiguide' => true, // default: true 'www_authenticate' => 'Bearer', // default: Bearer ]),
apiguide.dev
Every problem this package produces itself has a type from the apiguide.dev error catalogue, wherever the catalogue has an entry for it. Each type URI resolves to a page explaining when the error happens and how a client recovers, which is what section 3.1.1 asks a type URI to dereference to.
{
"type": "https://apiguide.dev/errors/precondition-failed",
"title": "Precondition Failed",
"status": 412,
"detail": "The resource has changed since you last fetched it. Fetch it again and retry with its current ETag."
}
Your own problem types can use the catalogue too, through JustSteveKing\Toolbox\Support\ApiGuide:
#[ProblemType(ApiGuide::ACCOUNT_FROZEN, title: 'Account Frozen', status: 403)] final class AccountFrozen extends ProblemException {}
ApiGuide is generated from the catalogue, so a new catalogue entry reaches this package in a
release. Maintainers regenerate it with composer apiguide, which reads the catalogue's main
branch, checks every type URI resolves, and reports any status that has gained a single entry for
someone to decide whether to map. A weekly workflow runs composer apiguide -- --check and fails
when the committed copy has fallen behind.
Set 'apiguide' => false to use about:blank with the status phrase for plain HTTP errors
instead (section 4.2.1), and to resolve the validation type against type_base_uri. Types a
feature names for its own errors, such as invalid-pagination-cursor, stay on apiguide.dev
either way, because they say more than the status does.
Typed problems
Describe each problem type once, on its class:
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 throw it with the details of this occurrence:
throw new OutOfCredit( detail: 'Your current balance is 30, but that costs 50.', extensions: ['balance' => 30], instance: '/account/12345/msgs/abc', );
HTTP/1.1 403 Forbidden Content-Type: application/problem+json {"type":"https://docs.example.com/problems/out-of-credit","title":"You do not have enough credit.","status":403,"detail":"Your current balance is 30, but that costs 50.","instance":"/account/12345/msgs/abc","balance":30}
The type, title and status live on the class because section 3.1.3 says a title should not
change between occurrences. Detail, instance and extensions belong to the occurrence. An
extension named after a standard member (type, title, status, detail, instance) is
rejected, because it would silently replace that member.
A problem class can inherit its #[ProblemType] from an abstract parent.
Generating a problem type
php artisan make:api-problem OutOfCredit --title="You do not have enough credit." --status=403
php artisan make:api-problem AccountFrozen --apiguide=account-frozen
The class goes in app/Problems; a name like Billing/CardDeclined makes
App\Problems\Billing\CardDeclined. Section 4 says a new problem type MUST document a type URI,
a title and its status, so the command will not write a class without all three: it prompts for
whatever you leave out, and fails under --no-interaction, where there is nobody to ask.
| Option | Does |
|---|---|
--title |
The title. Section 3.1.3: it should not change between occurrences |
--status |
The status, 4xx or 5xx |
--type |
A slug or type URI. Defaults to the class name in kebab case, resolved against type_base_uri |
--apiguide |
An apiguide.dev entry, such as account-frozen, for all three at once. Cannot be combined with the three above |
--force |
Overwrite an existing class |
#[ProblemType(ApiGuide::ACCOUNT_FROZEN, title: 'Account Frozen', status: 403)] final class AccountFrozen extends ProblemException {}
php artisan stub:publish includes stubs/api-problem.stub, and the command uses that copy once
it exists, for teams that want their own docblocks or extension members. The command is only
registered when Problem Details is enabled.
What else is rendered
| Exception | Status | type |
|---|---|---|
ProblemException |
from #[ProblemType] |
resolved from #[ProblemType] |
ValidationException |
422 | https://apiguide.dev/errors/validation-failed |
AuthenticationException |
401 | https://apiguide.dev/errors/unauthorized |
Any HTTP exception, including abort(), model not found, authorization |
its own | the catalogue entry for the status, or about:blank |
| Anything else | 500 | https://apiguide.dev/errors/internal-server-error |
A status gets a catalogue type only when one entry is the plain meaning of that status: 404 is
resource-not-found, 412 is precondition-failed, 429 is rate-limit-exceeded. Statuses with
several candidates, such as 400 and 422, get about:blank with the status phrase, as section
4.2.1 asks, rather than a type that claims more than the status says. Any headers the exception
carries, such as Retry-After or ETag, are kept.
A 401 carries a WWW-Authenticate challenge, because RFC 9110 section 15.5.2 says every 401 MUST.
Laravel's AuthenticationException carries none, so the package adds the one in
www_authenticate: Bearer by default, the scheme Sanctum and Passport tokens use. Set it to
match how your API authenticates, such as 'Bearer realm="api"' or 'Basic realm="admin"'. An
exception that already brings a challenge, such as Symfony's UnauthorizedHttpException, keeps
its own. false sends none; only do that if something else adds the header.
With Negotiation assigning request IDs, every problem gets an instance: the request ID as a
urn:uuid URI (RFC 9562 section 4), so an error a client reports leads straight to its log lines.
{"type":"https://apiguide.dev/errors/gone","title":"Gone","status":410,"instance":"urn:uuid:0199e7f4-5d2a-7c3e-9b1a-4f2d8e6c1a07"}
Section 3.1.5 says instance identifies the occurrence and need not be dereferenceable, which is
what a request ID is. An ID that is not a UUID, such as one a proxy assigned, has no standard URI
form and leaves instance out rather than becoming an invalid URI. An instance a problem class
sets itself is kept. Set instance_from_request_id to false to turn this off.
An unexpected exception gets only type, title and status. Its message and trace are added
only when app.debug is on, since section 5 warns against exposing implementation details.
To map an exception of your own to a problem, use Laravel's $exceptions->map() in
bootstrap/app.php to turn it into a ProblemException.
Validation errors
These use the errors member that apiguide.dev documents for its validation-failed type:
Laravel's messages, keyed by field.
{
"type": "https://apiguide.dev/errors/validation-failed",
"title": "Validation Failed",
"status": 422,
"detail": "The end date field must be a date after start date.",
"errors": {
"end_date": ["The end date field must be a date after start date."]
}
}
RFC 9457's own validation example, in section 3, uses a list of detail and JSON Pointer
objects instead. That example belongs to a fictional problem type. Section 3.2 lets each type
define its extensions, and a response has to match the type its type member points at, so this
follows the catalogue page.
Type URIs
A #[ProblemType] takes either an absolute URI, used as is, or a slug resolved against
type_base_uri. Full paths such as /types/123 also pass through untouched.
Section 3.1.1 recommends absolute type URIs, and says a relative one should carry the full
path, because a bare relative reference resolves against each request's own URL. So the default
base is the path /problems, never nothing. Point type_base_uri at your documentation to
follow the recommendation fully: section 3.1.1 also says dereferencing a type URI should give
human-readable documentation.
To resolve types some other way, such as against an external error catalogue, bind your own
JustSteveKing\Toolbox\ProblemDetails\Contracts\TypeResolver.
When problems are rendered
Problem details are rendered when the request expects JSON, or when its path matches one of
paths. Everything else is left to Laravel.
Requests that send or accept application/vnd.api+json are left alone. JSON:API has its own
error format, and section 4 of the RFC says problem details are not meant to replace
domain-specific formats.
Your own render callbacks in withExceptions() run first, so an application can always
override what this package renders.
Deprecation and Sunset
RFC 9745 (Deprecation), RFC 8594 (Sunset)
A changelog entry saying an endpoint is deprecated reaches the people who read changelogs. These two headers reach every client that calls the endpoint, in a form a client library or gateway can act on.
Features::deprecation([ 'global' => true, // default: true ]),
Mark a controller, or one action, with attributes:
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', link: 'https://docs.example.com/sunset-policy')] final class OrderController { // ... }
Deprecation: @1782863999 Sunset: Fri, 01 Jan 2027 00:00:00 GMT Link: <https://docs.example.com/deprecations/orders-v1>; rel="deprecation" Link: <https://docs.example.com/sunset-policy>; rel="sunset"
- An attribute on an action overrides the same attribute on its controller. The two kinds are
resolved separately, so a controller-wide
Sunsetcan sit beside a per-actionDeprecation. - A date without an offset is read as UTC.
atonDeprecationcan be in the future: section 2.1 says the date is when the resource "will be or has been deprecated". #[Deprecation(link: ...)]with no date sends only the link. Section 3.1 describes this as publishing a deprecation policy before anything is deprecated.- A sunset earlier than the deprecation throws. RFC 9745 section 4 says it MUST NOT be.
- Links are appended to any
Linkthe response already has, never replace it. - Error responses get the headers too, because both describe the resource, not the outcome of one request.
The attributes are named after the header fields because #[Deprecated] is taken: PHP 8.4's
native #[\Deprecated] raises E_USER_DEPRECATED every time the method is called, which on a
controller means on every request.
Only controller routes are read, as with Laravel's own controller attributes. Closure routes are
not, because route:cache serialises closures and their attributes cannot be relied on.
With global on, the middleware runs on every request and does nothing for routes without
attributes. With it off, add the deprecation middleware to the routes that need it.
Sunset dates
RFC 8594 makes Sunset an HTTP-date, which RFC 9110 section 5.6.7 fixes as
Sun, 06 Nov 1994 08:49:37 GMT. RFC 9745's own section 4 example writes
Sun, 30 Jun 2024 23:59:59 UTC, which is not a valid HTTP-date. This package sends GMT, and
assertSunset() rejects UTC.
Other sources
Attributes are one source of deprecation information. To read it from somewhere else, such as a
manifest shared with services in other languages, bind your own
JustSteveKing\Toolbox\Deprecation\Contracts\DeprecationSource. It returns a Notice for a
route, or null:
use JustSteveKing\Toolbox\Deprecation\Notice; new Notice( deprecatedAt: Notice::date('2026-06-30T23:59:59Z'), deprecationLink: 'https://docs.example.com/deprecations/orders-v1', sunsetAt: Notice::date('2027-01-01'), );
Field-level deprecation
Not supported. Both RFCs describe a resource, and no published standard puts the deprecation of
a single field in a header. Notice has no dependency on routes, so whatever does eventually
describe fields can produce it.
Conditional Requests
RFC 9110 section 13, RFC 6585 section 3
Two clients read a document, both edit it, and the second save silently overwrites the first. Entity tags and preconditions stop that lost update: the client says which version it read, and the server refuses the write if that is no longer current. The same validators let a client re-fetch a resource it already has and get a bodiless 304.
Features::conditionalRequests(),
It is opt-in per route:
Route::middleware('conditional')->group(function () { Route::get('documents/{document}', ShowDocument::class); Route::patch('documents/{document}', UpdateDocument::class)->middleware('preconditions.required'); });
| Middleware | Does |
|---|---|
conditional |
The group of the next two, in order |
preconditions |
Evaluates If-Match, If-None-Match, If-Modified-Since and If-Unmodified-Since in the order section 13.2.2 sets: 412 or 304 |
validators |
Adds ETag and Last-Modified to successful GET and HEAD responses. validators:weak makes a content-hash ETag weak |
preconditions.required |
428 for a PUT, PATCH or DELETE with no precondition. preconditions.required:PUT,POST names the methods |
Laravel 13's #[Middleware('conditional')] controller attribute works too.
The middleware run after route model binding, and 428 is decided before 412, wherever the route lists them, because the package adds them to Laravel's middleware priority.
Where validators come from
A write has to be checked before it happens (section 13.1.1), so the current ETag cannot come
from the response. It comes from the route-bound model, when it implements HasValidators:
use JustSteveKing\Toolbox\ConditionalRequests\Concerns\HasModelValidators; use JustSteveKing\Toolbox\ConditionalRequests\Contracts\HasValidators; final class Document extends Model implements HasValidators { use HasModelValidators; }
HasModelValidators gives a strong ETag from the model's class, key and updated_at, and
Last-Modified from updated_at. Section 8.8.1 says a strong tag must change whenever the
representation does, and that holds only while every change touches updated_at and two writes
never share one. MySQL's default timestamp columns store whole seconds, so two writes in the same
second would get the same tag. For optimistic concurrency, override entityTagVersion() to return
a version column that every write increments.
On a GET with no bound model, the ETag is a hash of the response content.
Behaviour worth knowing
If-Matchuses strong comparison (section 13.1.1), so a weak ETag can never satisfy it.If-None-Matchuses weak comparison (section 13.1.2).- A 412 carries the current
ETag, so the client knows what to re-fetch. - A 428 says how to retry, as RFC 6585 asks, and sends
Cache-Control: no-store, because the section says a 428 MUST NOT be stored by a cache. If-Match: *fails andIf-None-Match: *passes on a route with no bound resource, which lets a PUT create without overwriting.- Modification times are compared in whole seconds, the resolution of an HTTP-date.
- Validators are only added to GET and HEAD responses. Section 9.3.4 says a successful PUT MUST NOT carry one unless the content was stored exactly as received, which a package cannot know.
- GET preconditions are checked against the finished response, because only the finished response knows every header a 304 has to repeat (section 15.4.5).
- A 304 has no
Last-Modified. Laravel's router strips it from every 304, and section 15.4.5 only calls it useful when there is no ETag. - Entity tags are parsed by this package, not Symfony.
Request::getETags()splits on every comma, but a comma is a legal character inside an entity tag. If-RangeandRangeare not handled: the package does not serve range requests.- When a precondition fails, the package always sends 412. Sections 13.1.1 and 13.1.4 allow a 2xx instead when the change appears to have been applied already, but they also say a server is better off sending 412 for every failure on an unsafe method.
Caching directives
Laravel's own cache.headers middleware already writes Cache-Control, including
stale-while-revalidate and stale-if-error (RFC 5861)
and immutable (RFC 8246), so this package does not
replace it. Use it beside conditional, and leave its etag option off: conditional already
sends the ETag, from the model where there is one.
Route::get('documents/{document}', ShowDocument::class)->middleware([ 'conditional', 'cache.headers:public;max_age=60;stale_while_revalidate=30;stale_if_error=86400', ]);
assertCacheControl() checks the result.
Pagination
Laravel's paginators put navigation in the body, as links and meta beside the data, so every
client has to know that shape. Link headers carry the same navigation in a form any HTTP client
can read, and leave the body as the items alone.
Features::pagination(),
use JustSteveKing\Toolbox\Pagination\CursorPage; return CursorPage::of(Post::query()->cursorPaginate(20), PostResource::class);
Link: <https://api.example.com/posts?cursor=eyJpZCI6MjAsIl9wb2ludHNUb05leHRJdGVtcyI6dHJ1ZX0>; rel="next" Link: <https://api.example.com/posts>; rel="first" [{"id": 1, ...}, {"id": 2, ...}]
nextandprevappear when there is a page in that direction.firstis always there, and is the URL with no cursor.- There is no
last: a cursor only knows its neighbours. - The resource class is optional. Without it, the items are serialised as they are.
- Call
withQueryString()on the paginator to keep filters and sorting in every link.
Invalid cursors
Laravel treats a cursor it cannot decode as no cursor and serves the first page, which a client
paging through results cannot notice. With this feature on, an undecodable cursor is a 400
instead, typed https://apiguide.dev/errors/invalid-pagination-cursor, for every cursor
paginator in the application, before any query runs. A missing or empty cursor is still the first
page.
Idempotency
draft-ietf-httpapi-idempotency-key-header-07
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. With an Idempotency-Key,
the retry gets the original result instead of running again.
The draft expired on 2026-04-18 without becoming an RFC. This feature is supported anyway, not experimental: the header has kept the same shape through every revision and is widely used.
Features::idempotency([ 'ttl' => 86400, // seconds a result is kept, and so how long a key is valid 'store' => null, // cache store; it must support atomic locks 'lock' => 60, // longest a request is treated as in flight 'retry_after' => 1, // Retry-After on a 409 'documentation' => null, // URL linked from a 400, as the draft's example does ]),
Route::post('charges', CreateCharge::class)->middleware('idempotent'); // honoured when sent Route::post('payouts', CreatePayout::class)->middleware('idempotent:required'); // 400 when missing
POST /charges HTTP/1.1 Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"
| Situation | Response |
|---|---|
| First request with a key | Runs as normal; the response is stored |
| Retry, same payload | The stored response, unchanged, without running the action again |
| Same key, different payload | 422, https://apiguide.dev/errors/idempotency-key-conflict |
| Retry while the first is still running | 409, the same type, with Retry-After |
No key, idempotent:required |
400, linked to documentation if set |
| Malformed key | 400 |
The draft (section 2.5.2) says an API MUST publish its idempotency policy, including how long
keys last. Put your ttl in your API documentation.
- Both key forms work. Section 2.1 makes the key a quoted Structured Field String.
Widely used clients send it bare.
"abc"andabcare the same key. - Keys are scoped to the authenticated user, or to the client address when there is none, so two clients cannot collide on the same key. The middleware runs after authentication for this reason, wherever the route lists it.
- The fingerprint is a hash of the method, path and query, and the whole body. That's one of the approaches section 2.4 lists. The same key sent to another endpoint is a different request (422), not a retry.
- Only POST and PATCH. The other methods are idempotent already.
- Errors are stored and replayed too, as section 2.6 says: a retry of a request that failed validation fails the same way. Two exceptions: a 5xx is not stored, so a request that failed on the server can be retried, which is the point of the key; nor is a 429, because the request never ran.
- A replay is the stored response with its original status, headers and body, minus
Dateand cookies. With Negotiation on, it gets its own request ID andtraceparent, because a retry is a new request. No header marks it as a replay: the draft defines none. - The cache store has to support atomic locks, which is how a concurrent retry is told apart from a completed one. Redis, Memcached, database, file, DynamoDB and array do. APC, session and storage stores do not, and nor does the null store in practice, since it keeps nothing. The package refuses all of those rather than fail open.
RateLimit headers (experimental)
draft-ietf-httpapi-ratelimit-headers-11
Experimental. Built on an IETF draft that can still change. Breaking changes to this feature are allowed in minor releases. Pin a minor version if you rely on it.
Laravel tells a client its rate limit through X-RateLimit-* headers that no standard defines.
A client only finds out the window when it hits a 429. The draft's two fields describe the policy
and the current state in a form any client can parse.
Features::rateLimitHeaders([ 'legacy_headers' => true, // keep Laravel's X-RateLimit-* alongside ]),
Nothing changes in your routes. Laravel's throttle middleware and rate limiters work as before;
the responses gain two fields:
RateLimiter::for('api', fn (Request $request) => Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()));
RateLimit-Policy: "api";q=60;w=60 RateLimit: "api";r=59;t=60
And on a 429:
HTTP/1.1 429 Too Many Requests Content-Type: application/problem+json Retry-After: 42 RateLimit-Policy: "api";q=60;w=60 RateLimit: "api";r=0;t=42 Cache-Control: no-store, private {"type":"https://apiguide.dev/errors/rate-limit-exceeded","title":"Rate Limit Exceeded","status":429,"detail":"Too Many Attempts."}
- The policy name is the limiter's name. A limiter that returns several limits gets one policy
each, numbered:
"layered-1","layered-2".throttle:60,1has no name and is"default". qis the limit,wits window in seconds,rwhat is left andtthe seconds until the window resets.tis left out before a window has started.pk, the partition key, is never sent. Section 6.1 says to avoid partition keys that carry sensitive information, and Laravel's are user identifiers and IP addresses.Retry-Afteris the end of the effective window, never earlier, as section 6 requires.- A 429 carries
Cache-Control: no-store, because RFC 6585 section 4 says it MUST NOT be stored by a cache. - The 429 is typed
rate-limit-exceeded, for a short window that protects capacity. apiguide.dev'squota-exceededis for a billing-period allowance. Use it, through aProblemException, if that is what your limit is.
This works by binding Laravel's ThrottleRequests middleware to a subclass in the container.
Known gap: Redis throttling
Applications that call throttleWithRedis() get no RateLimit headers. Their 429s are still
rendered as problems, but without RateLimit, RateLimit-Policy or no-store.
That is deliberate for now, for three reasons:
- It is a different middleware.
throttleWithRedis()points thethrottlealias atThrottleRequestsWithRedis, so bindingThrottleRequestsnever reaches it. - It counts differently. It does not use Laravel's cache-backed
RateLimiter. Each limit is aDurationLimiter, a Lua script run in Redis, and the remaining count and reset time live in arrays on the middleware instance, filled as each limit is checked. Supporting it means a second subclass that mirrors that class's ownhandleRequest(), a second set of framework internals to track. - It cannot be tested here. Proving it works needs a Redis server in the test suite, and this feature is experimental already. Untested support for it would be worse than none.
The fix is clear once those are in place: the same PolicyHeaders builder, fed from that class's
remaining and decaysAt instead of the cache.
The draft does not say whether the policy item in RateLimit (section 4) must be a String the way
section 3 requires for RateLimit-Policy. Every example uses one, and the same name has to
identify the same policy in both, so this package always sends a String.
Preferences
APIs grow query parameters such as ?return=none or ?async=1 to let a client ask for a lighter
response or background processing. RFC 7240 already defines the header for this, Prefer, and
one for the server to say what it honoured, Preference-Applied.
Features::preferences(),
Route::middleware('prefer')->group(function () { Route::post('items', CreateItem::class); Route::patch('items/{item}', UpdateItem::class); });
| Preference | What happens |
|---|---|
return=minimal |
A successful response to an unsafe method is emptied: 200 becomes 204, other 2xx keep their status and headers, Location included (section 4.2) |
return=representation |
Reported in Preference-Applied when the response has a body |
handling=strict |
Form requests using HonoursHandlingPreference reject fields their rules do not know (section 4.4) |
respond-async |
Read by async operations |
wait |
Available to read; nothing acts on it by default |
Vary: Preferis always sent. Section 2 says a server that might vary a response on a preference MUST say so, whether or not this request used one.- GET and HEAD are left alone.
return=minimalis about the result of a change. - Unknown preferences and malformed ones are ignored, as section 2 requires, and only the first occurrence of each counts.
- Conflicting pairs cancel out.
return=minimalwithreturn=representation, orhandling=strictwithhandling=lenient, is treated as neither, as sections 4.2 and 4.4 suggest. Section 2's first-occurrence rule would otherwise pick one; both together mean a client bug, and guessing is worse than honouring neither.
Read preferences anywhere by injecting JustSteveKing\Toolbox\Preferences\Preferences, and
record one you honoured with $preferences->applied('wait', '10'); the prefer middleware writes
it to Preference-Applied.
Strict handling
use JustSteveKing\Toolbox\Preferences\Validation\HonoursHandlingPreference; final class StoreLead extends FormRequest { use HonoursHandlingPreference; }
Laravel ignores input it was not asked to validate. Under Prefer: handling=strict, a field with
no rule fails validation instead, so a client's typo is caught instead of silently dropped.
Section 4.4 leaves what counts as recoverable to the server; this is the part Laravel forgives.
Only top-level fields are checked, so a rule for items.*.sku covers all of items.
Async operations
RFC 9110 §15.3.3, RFC 7240 §4.1
A report that takes two minutes to build cannot be built inside a request. The usual answer is a 202 Accepted and a URL to check on progress, and every API builds that URL its own way. This builds it on Laravel's queues.
The standards stop at the 202. RFC 9110 says a 202 "ought to ... point to ... a status monitor", and RFC 7240 says what happens next is out of scope. The status monitor below is this package's convention, built from standard parts.
Features::asyncOperations([ 'path' => 'api/operations', // where status monitors live 'middleware' => ['api'], 'ttl' => 86400, // seconds a status is kept after its last change 'retry_after' => 5, // Retry-After while it runs ]),
public function store(Request $request, Operations $operations, Preferences $preferences) { if ($preferences->respondAsync()) { return $operations->dispatch(new GenerateReport($request->validated())); } return new ReportResource((new GenerateReport($request->validated()))->handle()); }
HTTP/1.1 202 Accepted Location: https://api.example.com/api/operations/0199e7f4-5d2a-7c3e-9b1a-4f2d8e6c1a07 Retry-After: 5 Preference-Applied: respond-async {"id":"0199e7f4-...","status":"pending","created_at":"...","updated_at":"..."}
The job says where its result is:
public function handle(CurrentOperation $operation): void { $report = Report::create(/* ... */); $operation->resultAt(route('reports.show', $report)); }
| Status | The monitor answers |
|---|---|
pending, running |
200 with the status, and Retry-After |
succeeded |
303 See Other to the result (RFC 9110 §15.4.4); 200 if the job named none |
failed |
200 with the status and an error problem object |
| unknown, expired, or someone else's | 404 |
- Any queued job works, as long as it uses Laravel's
Queueabletrait: the tracking is attached as job middleware. - Retries are followed. A failure with attempts left goes back to
pending; only the last attempt's failure isfailed. - Failures do not leak. An unexpected exception becomes
internal-server-errorwith no message, as RFC 9457 section 5 advises; an HTTP exception keeps its status and message. - Operations are private to whoever started them, when someone was signed in to start them.
- The monitor answers 200 for a failed operation, because the monitor itself was found. The failure is in its content.
- Status lives in the cache, like idempotency keys. A cache that evicts under memory pressure can lose a status early; the monitor then answers 404.
- A job that times out or is failed manually, without throwing, stays
runninguntil its TTL.
Merge Patch
Most Laravel APIs treat PATCH as a partial PUT: send the fields to change, and hope nobody needs to
clear one. JSON Merge Patch gives PATCH 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.
Features::mergePatch(),
use JustSteveKing\Toolbox\MergePatch\MergePatch; Route::patch('posts/{post}', function (Post $post, MergePatch $patch) { $document = $patch->applyTo($post->only(['title', 'summary', 'tags'])); Validator::make($document, [ 'title' => ['required', 'string'], 'summary' => ['nullable', 'string'], 'tags' => ['array'], ])->validate(); $post->update($patch->changesTo($post->only(['title', 'summary', 'tags']))); return new PostResource($post); })->middleware('merge-patch');
PATCH /posts/1 HTTP/1.1 Content-Type: application/merge-patch+json {"title":"Hello!","summary":null,"tags":["example"]}
- Validate the merged document, not the patch. In a patch,
nullmeans "remove", so rules written for the resource would misread it. What has to be valid is the result. applyTo()returns the merged document.changesTo()returns only the members the patch touches, with removed ones asnull, which is whatModel::update()wants.apply()is the section 2 algorithm on any decoded JSON value, for documents that are not arrays of attributes.{}and[]are different. The patch{}changes nothing; the patch[]replaces the whole document. PHP's usualjson_decode($json, true)turns both into[], so the patch is read from the raw body with objects kept as objects. The package's own tests are Appendix A's.- The
merge-patchmiddleware answers a PATCH in any other format with 415 andAccept-Patch: application/merge-patch+json, as RFC 5789 section 2.2 asks, and advertises the format on every response. A patch that is not JSON is a 400, typedhttps://apiguide.dev/errors/malformed-request-body. - JSON Patch (RFC 6902) is not supported. It is a different, operation-based format, and few APIs need it.
Digest Fields
TLS protects a message in transit, not the content an intermediary decompressed, re-encoded or cut
short. Content-Digest and Repr-Digest carry a hash of the content, so either side can check it
arrived intact.
Features::digests(),
Route::put('files/{file}', StoreFile::class)->middleware('digest.verify'); // check when sent Route::put('ledger/{entry}', StoreEntry::class)->middleware('digest.verify:required'); // and require one Route::get('files/{file}', ShowFile::class)->middleware('digest.respond'); // when asked Route::get('ledger/{entry}', ShowEntry::class)->middleware('digest.respond:always'); // always
PUT /files/123 HTTP/1.1 Content-Type: application/json Content-Digest: sha-256=:RK/0qy18MlBSVnWgjwz6lZEWjP/lF5HF9bvEF8FabDg=: {"hello": "world"}
Verifying requests. Every sha-256 or sha-512 digest in Content-Digest and Repr-Digest
has to match the body as received. A failure is a 400 with one of the three problem types the
httpapi working group's draft defines, each with the draft's extension members:
| Problem | When | Extension member |
|---|---|---|
https://apiguide.dev/errors/digest-mismatched-values |
A digest does not match the content | mismatched_digests |
https://apiguide.dev/errors/digest-invalid-values |
A value cannot be a digest of its algorithm, such as the wrong length | invalid_digests |
https://apiguide.dev/errors/digest-unsupported-algorithms |
No member uses an algorithm this supports | unsupported_algorithms |
- Only sha-256 and sha-512 are supported, the two algorithms section 7.2 registers as Active. The Deprecated ones (md5, sha, crc32c and others) MUST NOT be used where an attacker could be involved (section 5), which describes any public API.
- Unsupported members beside a supported one are ignored, as section 2 allows, so a client can send a new algorithm alongside one the server knows.
- One bad digest fails the request, even with a good one beside it. Accepting the request on the strength of whichever digest matches would be a weakness.
- The server's own digest is never sent back on a mismatch, as the draft says, so a response cannot be used as an oracle.
- The problem types live on apiguide.dev until the draft's are registered with IANA. They
mirror the draft's
https://iana.org/assignments/http-problem-types#digest-...types and use its extension member names, so moving to the IANA types will change only the URIs.
Adding to responses. digest.respond adds the digests a client asks for with
Want-Content-Digest or Want-Repr-Digest, using the algorithm it prefers most (section 4).
Wants it cannot meet are ignored rather than refused, since section 4 makes them hints.
digest.respond:always sends Repr-Digest on every response.
HEAD responses get no digest. RFC 9530 allows Repr-Digest on one, but Laravel's router has
already removed a HEAD response's content by the time route middleware sees it, so the digest
would be of nothing. 204 and 304 responses, and streamed ones, get none either.
Message Signatures
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. HTTP Message Signatures is the IETF's single answer: one way to say which parts of a message are signed, by which key, when, and with what.
Features::messageSignatures([ 'keys' => [ 'webhooks' => ['algorithm' => 'hmac-sha256', 'secret' => env('WEBHOOK_SECRET')], 'partner' => ['algorithm' => 'ed25519', 'public_key' => storage_path('keys/partner.pem')], 'server' => ['algorithm' => 'ecdsa-p256-sha256', 'private_key' => storage_path('keys/server.pem')], ], 'verify' => [ 'required' => ['@method', '@authority', '@path'], // components every signature must cover 'max_age' => 300, // seconds since created 'leeway' => 30, // seconds of clock skew allowed 'cover_content' => true, // require and check content-digest on a body 'tag' => null, // a tag every signature must carry 'require_nonce' => false, ], 'sign' => ['key' => 'server'], // the key signature.sign uses by default ]),
Signing webhooks
Http::withSignature('webhooks')->post($endpoint, $payload);
POST /orders HTTP/1.1 Content-Type: application/json Content-Digest: sha-256=:...: Signature-Input: sig1=("@method" "@target-uri" "content-type" "content-digest");created=1760000000;keyid="webhooks" Signature: sig1=:...:
By default the signature covers the method, the full target URI, Content-Type, and a
Content-Digest of the body, which is added for you. Choose other components, a label, or expires,
nonce and tag parameters with the remaining arguments:
withSignature('partner', ['@method', '@authority'], 'partner-sig', ['tag' => 'orders']).
Verifying requests
Route::post('webhooks/orders', HandleOrderWebhook::class)->middleware('signature.verify'); VerifiedSignature::of($request)->keyId; // who signed it
A request passes when one of its signatures, from a known key, meets every rule below. The order follows apiguide.dev's advice: verify the signature, then the digest, then the age.
- The key is known. An unknown
keyidnever verifies (section 3.2, step 5). - The algorithm is the key's. The algorithm is configured with the key, and an
algparameter that disagrees fails. Section 7.3.6 describes the downgrade this prevents, where an attacker asks for HMAC and a naive verifier uses a public key as the secret. - It covers what you require, and
content-digesttoo when the request has a body. - The signature matches.
- The body matches
Content-Digest. A signature overcontent-digestonly proves the header was not changed; without recomputing the digest over the body actually received, the body is not covered at all. - It is fresh.
createdis required, no older thanmax_ageand no further in the future thanleeway, andexpires, if set, has not passed.
Signatures are matched on their key and coverage, never their label: labels are the signer's choice, and a proxy may add a signature of its own (section 4.3), which is ignored rather than fatal.
A failure is a 401, typed https://apiguide.dev/errors/unauthorized, whose detail names the check
that failed but nothing that would help forge a signature. It carries Accept-Signature
(section 5.1), saying what to sign next time. Signature fields that do not parse are a 400.
nonce uniqueness is not tracked: require_nonce checks one is present, and a short max_age
limits replay. Track nonces in your application if a replay inside that window matters.
Signing responses
Route::get('orders/{order}', ShowOrder::class)->middleware('signature.sign'); // or signature.sign:other-key
Covers @status, Content-Type, a Content-Digest of the body (added if missing), and the
request's method and path with req (section 2.4), so a response cannot be passed off as the
answer to a different request.
Algorithms
| Algorithm | Keys | PHP |
|---|---|---|
hmac-sha256 |
secret (prefix with base64: to give it encoded) |
any |
ed25519 |
PEM, or raw sodium keys | any, through sodium |
ecdsa-p256-sha256, ecdsa-p384-sha384 |
PEM | any |
rsa-v1_5-sha256 |
PEM | any |
rsa-pss-sha512 |
An RSA-PSS key (openssl genpkey -algorithm RSA-PSS) |
any |
| A plain RSA key | 8.5 or later |
PHP's OpenSSL binding only gained a way to ask for PSS padding in PHP 8.5. An RSA-PSS key carries
the padding in its type, so it works everywhere; a plain RSA key used for rsa-pss-sha512 before
8.5 is refused with an explanation rather than signed with the wrong padding.
To find keys somewhere other than config, per client, from a database or a JWKS endpoint, bind your
own JustSteveKing\Toolbox\MessageSignatures\Contracts\KeyResolver.
Conformance
The signature base is built exactly as section 2.5 describes, including the sf, key and bs
field parameters, @query-param, and req, and is checked against every test case in the RFC's
Appendix B, byte for byte. The signatures the RFC publishes verify, and the deterministic ones,
HMAC and Ed25519, are reproduced exactly. The Structured Field parser underneath passes all 2,142
cases in the HTTP working group's test suite.
Trailer fields (tr) are not supported: PHP does not see a request's trailers.
Moving from juststeveking/laravel-webhooks
That package signed webhooks with an HMAC in a Signature header of its own design. This does the
same job with the standard, so any receiver that speaks RFC 9421 can verify it:
// Before Webhook::to($url)->with($payload)->send(); Webhook::to($url) ->with($payload) ->intercept(fn (PendingRequest $request) => $request->withToken($token)) ->queue('webhooks'); // After Http::withSignature('webhooks')->post($url, $payload); SendWebhook::dispatch($url, $payload)->onQueue('webhooks'); // where SendWebhook::handle() does: Http::withSignature('webhooks')->withToken($token)->post($this->url, $this->payload);
Queueing and retrying deliveries is Laravel's job: put $tries and $backoff on the queued job.
notSigned() has no equivalent; leave out withSignature(). Receivers verifying the old
Signature header need updating to RFC 9421; signature.verify is that receiver for Laravel.
API Catalog
RFC 9727 gives a publisher one place to list its APIs:
/.well-known/api-catalog. Clients, crawlers and agents that know nothing else about you can
find each API there, and where its OpenAPI description and documentation live.
Features::apiCatalog([ 'apis' => [ [ 'href' => '/api/v1', 'service-desc' => ['href' => '/openapi.json', 'type' => 'application/vnd.oai.openapi+json'], 'service-doc' => ['href' => 'https://docs.example.com', 'type' => 'text/html'], 'status' => '/api/v1/health', ], ], ]),
href is the API itself. The other keys are the RFC 8631
relations: service-desc (a machine-readable description), service-doc (documentation for
people), service-meta (metadata such as policies) and status. Each is a URL, a target with
href and any of type, title, hreflang and media, or a list of either. Relative URLs are
resolved against the application URL, because RFC 9264 §4.2.2 says a linkset SHOULD NOT use
relative references.
GET returns:
HTTP/1.1 200 OK Content-Type: application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727" Link: <https://example.com/.well-known/api-catalog>; rel="api-catalog" Cache-Control: max-age=3600, public {"linkset":[ {"anchor":"https://example.com/.well-known/api-catalog", "item":[{"href":"https://example.com/api/v1"}]}, {"anchor":"https://example.com/api/v1", "service-desc":[{"href":"https://example.com/openapi.json","type":"application/vnd.oai.openapi+json"}], "service-doc":[{"href":"https://docs.example.com","type":"text/html"}], "status":[{"href":"https://example.com/api/v1/health"}]} ]}
RFC 9727's appendix shows two shapes, and the spec does not say which to use. A.1 anchors each
API's RFC 8631 links at the API. A.2 lists APIs from the catalog with item. This package
uses both: the catalog lists every API with item, which meets §4.1's MUST that a catalog link
to its APIs, and each API that has metadata gets its own entry, which is the metadata §4.1
recommends. A client reading either shape finds what it is looking for.
HEAD is answered with the same Link field, as §2 requires.
Other catalogs are linked with api-catalog (§4.3), for example one per business unit:
Features::apiCatalog(['catalogs' => ['https://payments.example.com/.well-known/api-catalog']]),
To build the catalog from somewhere else, such as a database or an API gateway, bind your own
JustSteveKing\Toolbox\ApiCatalog\Contracts\CatalogSource.
To point to the catalog from your API's own responses, as §3 shows for a home page, add the
api-catalog.link middleware:
Route::get('api', ApiRoot::class)->middleware('api-catalog.link');
Security
RFC 9727 §8 says the catalog SHOULD be served over TLS and recommends rate limiting it. TLS is
your server's job. For rate limiting, the route takes the middleware option:
'middleware' => ['throttle:60,1']. The catalog is public, so list only the APIs you mean
anyone to find; §8 warns against exposing internal ones.
Negotiation
RFC 9110 §12, W3C Trace Context Level 1
Three things that sit between a request and its handler: refusing what a route cannot produce or consume, joining a request to its distributed trace, and giving it an ID.
Features::negotiation([ 'request_id_header' => 'X-Request-Id', // false turns request IDs off 'trust_request_id' => true, // keep a well-formed incoming ID 'trace_context' => true, 'propagate_trace' => true, // onto outgoing HTTP client requests ]),
406 and 415
An API that only speaks JSON answers an Accept: application/xml client with JSON anyway, and
accepts a form post it then fails to read. Declaring what a route produces and consumes turns
both into a clear error.
Route::post('leads', CreateLead::class) ->middleware(['produces:application/json', 'consumes:application/json']); Route::patch('leads/{lead}', UpdateLead::class) ->middleware('consumes:application/merge-patch+json');
produces answers 406, typed https://apiguide.dev/errors/not-acceptable, when the Accept
header rules out every type listed. consumes answers 415, typed
https://apiguide.dev/errors/unsupported-media-type, when the request has content in any other
type.
Acceptis evaluated as section 12.5.1 says: quality values, wildcards, and the most specific matching range deciding a type's quality.q=0means not acceptable, even under*/*. A missingAcceptaccepts anything.- Section 12.5.1 lets a server ignore an unsatisfiable
Acceptinstead. Addingproducesto a route is choosing strict negotiation. - Responses from a
producesroute carryVary: Accept, since whether they are a 406 depends on it. - The 406 lists what the route can produce in its
detail, which section 15.5.7 says it SHOULD. - Error responses are not held to
produces: RFC 9457 section 3 notes a problem can be sent asapplication/problem+jsoneven when the client did not list it. - The 415 carries an
Acceptheader naming the supported types, as section 15.5.16 suggests, and a refused PATCH also carriesAccept-Patch, which RFC 5789 section 2.2 says it SHOULD. - A request with no content is never refused. One with content and no
Content-Typeis: section 8.3 lets a server guess, and a guess is not a type the route said it consumes. - Types match on type and subtype, case-insensitively.
consumes:application/jsonacceptsapplication/json; charset=utf-8, and does not acceptapplication/merge-patch+json; list it.
Trace context
Each request joins the W3C trace its caller started, or starts one. The trace ID and the span ID
for this request are put in Laravel's log context, so every log line written while handling it
carries them, and so do queued jobs it dispatches. Laravel HTTP client requests carry the trace
on in traceparent, with tracestate forwarded unchanged. That outgoing propagation is the core
of the specification (section 4.3).
GET /orders HTTP/1.1 traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 HTTP/1.1 200 OK traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-b7ad6b7169203331-01
- A valid
traceparentis continued: same trace ID, a new parent ID for this request, flags kept. - No
traceparent, or one the specification says to reject (versionff, uppercase or all-zero IDs, extra fields on version 00), starts a new trace and drops anytracestate. A higher version is read as far as version 00 goes, as section 3.2.4 describes. - Flags version 00 does not define are cleared before anything is sent on (section 4.3).
- A response only gets a
traceparentwhen the request brought a valid one. Level 1 defines it as a request header; section 6.3 notes vendors send it on responses too, and asks that they only do so to systems that took part in the trace. - An outgoing request that already has a
traceparent, from OpenTelemetry instrumentation for example, is left alone.
Request IDs
No standard defines this. It is included deliberately, because a request ID is how a support ticket, a log line and a response are tied together, and it is the one part of the package not built on a published specification.
Every request gets an ID, on the response and in the log context as request_id. X-Request-Id
is the default because it is what most proxies, load balancers and hosting platforms already use;
set request_id_header to use another name.
- An incoming ID of 1 to 200 visible ASCII characters is kept, so one ID can follow a request
through a proxy. Set
trust_request_idtofalseto always assign your own. - Otherwise the ID is a UUIDv7, so IDs sort roughly by time in logs.
- Error responses carry it too.
Testing
Assertions for every enabled feature, on Laravel's TestResponse and as Pest expectations.
They check the exact syntax each standard specifies, not only that a header is present. They are
registered only while the application is running its tests (APP_ENV=testing), and only for
features that are enabled. The Pest expectations need Pest; the response macros work with
PHPUnit alone.
$this->getJson('/api/orders') ->assertProblem(403) ->assertProblemType('https://docs.example.com/problems/out-of-credit'); expect($this->getJson('/api/orders'))->toBeProblem(403);
| Feature | Response macro | Pest expectation | Checks |
|---|---|---|---|
| Problem Details | assertProblem(?status) |
toBeProblem(?status) |
application/problem+json; a JSON object; member types; status matches the response (RFC 9457 §3.1.2) |
assertProblemType(type, ?status) |
toHaveProblemType(type, ?status) |
The above, and the type. A missing type counts as about:blank (§3.1.1) |
|
| Deprecation | assertDeprecated(?at, ?link) |
toBeDeprecated(?at, ?link) |
Exactly one Deprecation field, a Structured Field Date (RFC 9745 §2.1), and a rel="deprecation" link if given |
assertSunset(?at, ?link) |
toHaveSunset(?at, ?link) |
Exactly one Sunset field, an IMF-fixdate in GMT (RFC 8594 §3), not earlier than Deprecation (RFC 9745 §4) |
|
assertNotDeprecated() |
Neither field is present | ||
| Conditional Requests | assertETag(?etag, ?weak) |
toHaveETag(?etag, ?weak) |
Exactly one ETag field, valid entity-tag syntax (RFC 9110 §8.8.3), and the tag or strength if given |
assertNotModified() |
toBeNotModified() |
304 with no content (RFC 9110 §15.4.5) | |
assertPreconditionFailed() |
toBePreconditionFailed() |
412 | |
assertPreconditionRequired() |
toBePreconditionRequired() |
428 that explains itself and carries no-store (RFC 6585 §3) |
|
| Idempotency | assertIdempotentReplay(original) |
toBeIdempotentReplayOf(original) |
The same status, content and headers as the original response, other than those that identify the message: Date, cookies, traceparent and the request ID header (draft §2.6) |
| RateLimit headers | assertRateLimitHeaders(?policy, ?remaining) |
toHaveRateLimitHeaders(?policy, ?remaining) |
Both fields parse as RFC 9651 Lists with the draft's parameters and types (draft §3, §4), and describe the policy if given |
| Negotiation | assertTraceparent(?traceId) |
toHaveTraceparent(?traceId) |
A valid version 00 traceparent (Trace Context §3.2), in the given trace if one is passed |
assertRequestId(?id) |
toHaveRequestId(?id) |
The configured request ID header is present, with the given value if one is passed | |
| Conditional Requests | assertCacheControl(directives) |
toHaveCacheControl(directives) |
Cache-Control parses (RFC 9111 §5.2) and has each directive, with its value where given |
| Preferences, Async | assertPreferenceApplied(name, ?value) |
toHavePreferenceApplied(name, ?value) |
Preference-Applied lists the preference (RFC 7240 §3) |
assertPreferenceNotApplied(name) |
It does not | ||
assertOperationAccepted() |
202 with Location and Retry-After; returns the monitor URL |
||
| Digest Fields | assertContentDigest() |
toHaveContentDigest() |
Content-Digest is a Dictionary of Byte Sequences, and each sha-256 or sha-512 member matches the content (RFC 9530 §2) |
assertReprDigest() |
toHaveReprDigest() |
Likewise for Repr-Digest (§3) |
|
| Merge Patch | assertAcceptsPatch(?mediaType) |
Accept-Patch lists the media type (RFC 5789 §3.1), application/merge-patch+json by default |
|
| API Catalog | assertApiCatalog(?api) |
toBeApiCatalog(?api) |
application/linkset+json; linkset is the sole member (RFC 9264 §4.2.1); every target is in an array and has an href; the API is listed with item if given |
| Deprecation, Pagination, API Catalog | assertLinkHeader(rel, ?uri) |
toHaveLink(rel, ?uri) |
Every Link field parses (RFC 8288 §3), and one has the relation type and target |
assertNoLinkHeader(rel) |
No Link with that relation type |
Dates can be a DateTimeInterface or a string. A string without an offset is read as UTC.
Configuration reference
Every option, with its default. Options are passed inline to the feature's method in
config/api-toolbox.php.
| Feature | Option | Default | Meaning |
|---|---|---|---|
problemDetails |
type_base_uri |
/problems |
Base URI for type slugs in #[ProblemType] |
paths |
['api/*'] |
Paths that always get problem details, whatever Accept says |
|
apiguide |
true |
Type plain HTTP errors and validation with the apiguide.dev catalogue | |
www_authenticate |
Bearer |
The challenge sent with a 401; false for none |
|
instance_from_request_id |
true |
Set instance to the request ID as a urn:uuid |
|
deprecation |
global |
true |
Run on every request; otherwise use the deprecation middleware |
conditionalRequests |
No options. Opt in per route with conditional |
||
pagination |
No options. Use CursorPage |
||
idempotency |
ttl |
86400 |
Seconds a result is kept, and so how long a key is valid |
store |
default store | Cache store; must support atomic locks | |
lock |
60 |
Longest a request is treated as in flight | |
retry_after |
1 |
Retry-After seconds on a 409 |
|
documentation |
null |
URL linked from a 400 as rel="describedby" |
|
preferences |
No options. Opt in per route with prefer |
||
asyncOperations |
path |
api/operations |
Where status monitors live |
middleware |
['api'] |
Middleware the status monitor runs behind | |
ttl |
86400 |
Seconds a status is kept after its last change | |
retry_after |
5 |
Retry-After seconds while an operation runs |
|
store |
default store | Cache store for statuses | |
mergePatch |
No options. Opt in per route with merge-patch |
||
digests |
No options. Opt in per route with digest.verify and digest.respond |
||
messageSignatures |
keys |
[] |
Keys by keyid: algorithm, and secret, private_key or public_key |
verify.required |
['@method', '@authority', '@path'] |
Components every accepted signature covers | |
verify.max_age |
300 |
Seconds a signature's created may be in the past |
|
verify.leeway |
30 |
Seconds it may be in the future | |
verify.cover_content |
true |
Require and check content-digest on a request with a body |
|
verify.tag |
null |
A tag every accepted signature carries | |
verify.require_nonce |
false |
Require a nonce |
|
sign.key |
null |
The key signature.sign uses when none is named |
|
apiCatalog |
apis |
[] |
The APIs to list: href, then any of service-desc, service-doc, service-meta and status |
catalogs |
[] |
Other API catalogs to link to | |
max_age |
3600 |
Cache-Control max-age in seconds; 0 for none |
|
middleware |
[] |
Middleware the catalog route runs behind | |
rateLimitHeaders |
legacy_headers |
true |
Keep Laravel's X-RateLimit-* headers |
negotiation |
request_id_header |
X-Request-Id |
Request ID header name; false turns request IDs off |
trust_request_id |
true |
Keep a well-formed incoming request ID | |
trace_context |
true |
Join requests to their W3C trace | |
propagate_trace |
true |
Send the trace on in HTTP client requests |
| Middleware | Feature | Does |
|---|---|---|
deprecation |
Deprecation | Deprecation, Sunset and their links, when global is off |
conditional |
Conditional Requests | preconditions then validators |
preconditions |
Conditional Requests | Evaluates If-* headers: 412 or 304 |
validators |
Conditional Requests | Adds ETag and Last-Modified; validators:weak for a weak hash |
preconditions.required |
Conditional Requests | 428 without a precondition; preconditions.required:PUT,POST |
idempotent |
Idempotency | Honours Idempotency-Key; idempotent:required for 400 without one |
prefer |
Preferences | return=minimal and Preference-Applied, with Vary: Prefer |
merge-patch |
Merge Patch | 415 with Accept-Patch for any other PATCH format |
digest.verify |
Digest Fields | Checks Content-Digest and Repr-Digest; digest.verify:required for 400 without one |
digest.respond |
Digest Fields | Adds the digests a client wants; digest.respond:always for Repr-Digest on every response |
signature.verify |
Message Signatures | 401 without a valid signature meeting the verify policy |
signature.sign |
Message Signatures | Signs the response; signature.sign:key-id names the key |
api-catalog.link |
API Catalog | Adds Link: <...>; rel="api-catalog" to the response |
produces |
Negotiation | 406; produces:application/json |
consumes |
Negotiation | 415; consumes:application/json,application/merge-patch+json |
Request IDs and trace context run as global middleware. RateLimit headers take over Laravel's
throttle middleware. Neither needs adding to routes.
License
MIT. See LICENSE.md.