API Design
Building production ready APIs with kit
A practical guide to shaping production-ready APIs using kit, from contracts and validation to observability and rollout.
Feb 202631 min readUpdated Oct 2026
Updated in October 2026 for kit on Laravel 13. Kit now runs its HTTP behaviour through api-toolbox, returns errors as problem details, and adds API keys, an endpoint generator, webhooks and a production image. For why those changes were made, read A Laravel API Starter Kit That Keeps Its Promises.
I have lost count of the number of API projects I have been brought in to look at, and I can tell you that almost none of them were in trouble because the team couldn’t build endpoints. Building endpoints is the easy part. They were in trouble because the same avoidable problems kept showing up, sprint after sprint.
Inconsistent response shapes. Auth that works but nobody can audit. Security controls bolted on after an incident. Docs that were accurate six months ago. Versioning handled differently by every developer on the team.
I built kit to deal with that. It’s an opinionated Laravel starter focused on token-based auth and API correctness, running on PHP 8.5 and Laravel 13. The goal is straightforward: stop rebuilding platform plumbing on every project and start spending effort on the domain logic that actually makes your product different.
This tutorial walks you through all of it: how to boot it, why each decision was made, what problem it solves, and how to extend it the right way when you start adding your own domain.
Prerequisites
You’ll need:
- PHP 8.5 or higher
- Composer
- The Laravel installer
- A terminal you’re comfortable in
You don’t need a database server running locally. Kit uses SQLite by default, which means zero infrastructure setup to get your first request working.
What Kit Actually Is
At its core, kit is an API-oriented Laravel baseline. No global /api prefix. No wildcard token abilities. No documentation that lives in a wiki someone forgot to update.
Here’s what you get out of the box:
- Versioned routing at
/v1/... - Sanctum tokens with scoped abilities, for sign-in sessions and for long-lived API keys
- Errors as RFC 9457 problem details, typed from the apiguide.dev catalogue
- Idempotency keys, cursor pagination, ETags, request IDs and deprecation headers from api-toolbox
- Localized API messages via
Accept-LanguageandContent-Language - Attribute-driven API docs with Scribe and OpenAPI output, checked by contract tests and graded for agent readiness
- Signed, retried, recorded webhooks for your API’s consumers
- Security hardening, audit logging, and production safety checks
- A production image on Octane and FrankenPHP
- GitHub Actions for CI, weekly audited dependency updates, and security auditing
It is not a full product scaffold. There’s no billing module, no admin UI, no multi-tenant setup. Kit focuses on API platform concerns and does that job well. If you need a UI-first Laravel monolith, this probably isn’t your starting point.
Getting the Project Running
Kit is on Packagist, so the Laravel installer can create a project from it:
laravel new my-api --using=juststeveking/kitcd my-apiphp artisan serveThe installer copies .env.example to .env, generates the app key, creates the SQLite database and runs the migrations, so the API answers straight away. Verification and password reset emails are queued, so run a worker alongside the server:
php artisan queue:workMail goes to the log until you configure a mailer, which is all you need locally.
Your base API URL is http://127.0.0.1:8000/v1.
One thing worth noting straight away: there is no /api prefix. That’s intentional. Adding an /api prefix to every URL is a habit from Laravel’s defaults that doesn’t actually serve API clients. The version prefix carries all the routing information you need.
Understanding the Environment Configuration
Open .env.example and you’ll see a few config values that are specific to kit:
APP_LOCALE=enAPP_FALLBACK_LOCALE=enAPP_SUPPORTED_LOCALES=en,es
SANCTUM_EXPIRATION=120API_KEY_DEFAULT_DAYS=90API_KEY_MAX_DAYS=365
SECURITY_FORCE_HTTPS=falseTRUSTED_HOSTS=localhost,127.0.0.1TRUSTED_PROXIES=*CORS_ALLOWED_ORIGINS=http://localhost:3000APP_SUPPORTED_LOCALES is a comma-separated list of locales the API will accept from clients. Any Accept-Language header outside this list falls back to APP_FALLBACK_LOCALE. This is how you keep localization support manageable without accepting arbitrary locale strings.
SANCTUM_EXPIRATION sets the lifetime of a sign-in token in minutes, 120 by default. The two API_KEY_ values set the default and the maximum lifetime of API keys, which are covered below. Every token carries its own expiry, so a long-lived key never shortens a session and a session never cuts a key short. In production you’ll want to think carefully about what fits your threat model. Short-lived tokens reduce the blast radius of a token leak. Long-lived tokens reduce friction for scripts and integrations. There’s no universal right answer, which is why kit gives you both.
SECURITY_FORCE_HTTPS is off locally but the production readiness checker will fail if you try to deploy without enabling it. That’s on purpose.
The file also carries settings for idempotency (IDEMPOTENCY_TTL) and webhooks (WEBHOOKS_*). The defaults are sensible, and the README explains each one.
The Routing Structure
Most Laravel projects dump everything into routes/api.php and call it done. Kit splits routing into two files with a clear separation of concerns.
routes/api/routes.php handles version grouping:
Route::prefix('v1')->middleware('consumes:application/json')->group(base_path( path: 'routes/api/v1.php',));routes/api/v1.php holds the concrete endpoint declarations for this version. When you add v2, you create routes/api/v2.php and add a new prefix group. The versions never bleed into each other. Route names follow the same shape, v1.auth.login, so they read the same in code and in php artisan route:list.
The full auth endpoint table for v1:
| Method | Path | Auth Required | Purpose |
|---|---|---|---|
| POST | /v1/auth/register | No | Register and receive a token |
| POST | /v1/auth/login | No | Login and receive a token |
| GET | /v1/auth/me | Bearer | Current authenticated user |
| POST | /v1/auth/logout | Bearer | Revoke the current token |
| POST | /v1/auth/email/verification-notification | Bearer | Send or resend a verification email |
| GET | /v1/auth/email/verify/{id}/{hash} | Signed URL | Verify email address |
| POST | /v1/auth/password/forgot | No | Request a password reset email |
| GET | /v1/auth/password/reset/{token} | No | Return reset payload for API clients |
| POST | /v1/auth/password/reset | No | Complete the password reset |
| GET | /v1/auth/tokens | Bearer | List the user’s tokens, paginated |
| POST | /v1/auth/tokens | Bearer | Create an API key |
| DELETE | /v1/auth/tokens/{token_id} | Bearer | Revoke a specific token |
| DELETE | /v1/auth/tokens | Bearer | Revoke all tokens |
Notice that the token management endpoints exist as first-class routes, not as an afterthought. That’s something most starters skip entirely. There is a second group under /v1/webhooks for webhook subscriptions, which gets its own section later.
The Middleware Pipeline
Every API request passes through a short pipeline before it hits a controller. Understanding what each layer does helps you reason about request behavior and debug issues quickly.
Two pieces come from api-toolbox and run on every request: a request ID, and W3C trace context. Kit adds its own to the api group:
$middleware->prependToGroup('api', AddSecurityHeaders::class);$middleware->prependToGroup('api', EnforceTransportSecurity::class);$middleware->prependToGroup('api', SetRequestLocale::class);$middleware->appendToGroup('api', RetireSunsetEndpoints::class);And the v1 group adds consumes:application/json, which you saw in the routing section.
consumes:application/json
Write requests must send JSON. If a client sends a form-encoded body, or any other content type, it gets a 415 Unsupported Media Type with an Accept header naming the type the route does take.
This sounds strict, and it is. But it eliminates an entire class of bugs where a client sends form-encoded data and the server silently accepts it, resulting in partially parsed payloads and confusing validation errors. A route that genuinely takes another type, such as a file upload, lifts the group’s declaration and adds its own.
EnforceTransportSecurity
This handles HTTPS enforcement and HSTS headers based on your environment config. In local development with SECURITY_FORCE_HTTPS=false, it’s a passthrough. In production with the flag enabled, a request over plain HTTP gets a 400 problem saying HTTPS is required, and secure responses get a Strict-Transport-Security header.
It relies on the trusted proxy configuration, which matters when you’re sitting behind a load balancer: the proxy terminates TLS, so the application only knows a request was secure, and who the client really was, if it trusts the proxy’s forwarded headers. That also keeps the client IP accurate for rate limiting and audit logging.
SetRequestLocale
This reads the Accept-Language header, resolves it against APP_SUPPORTED_LOCALES, sets the application locale for the duration of the request, and adds Content-Language to the response. If the requested locale isn’t supported, it falls back to APP_FALLBACK_LOCALE without erroring.
The result is that all translated strings in lang/en/api.php and lang/es/api.php are served in the right language automatically, without any per-controller logic.
AddSecurityHeaders
Every API response gets X-Content-Type-Options: nosniff, X-Frame-Options: DENY and Referrer-Policy: no-referrer. Cheap, boring, and the kind of thing nobody remembers to add later.
Request IDs
Every request gets an X-Request-Id header in the response. If the client or a proxy sends a well-formed one, it’s kept. If not, a UUIDv7 is generated, so IDs sort roughly by time. The ID is also attached to the log context, which means every log entry for a given request shares it. When something goes wrong in production, you can grep for the request ID and see exactly what happened, in order.
RetireSunsetEndpoints
This one only matters when you deprecate an endpoint, so it has its own section later.
Errors: Problem Details
Laravel returns errors as {"message": "..."}, with a different shape again for validation. Kit returns every error as RFC 9457 problem details, application/problem+json, rendered by api-toolbox:
{ "type": "https://apiguide.dev/errors/unauthorized", "title": "Unauthorized", "status": 401, "detail": "Unauthenticated.", "instance": "urn:uuid:0199e7f4-5d2a-7c3e-9b1a-4f2d8e6c1a07"}The type identifies the error and links to a page explaining it, so clients branch on it rather than on message text. The detail is the human-readable message, translated into the request’s locale. Validation errors add an errors member with Laravel’s messages keyed by field. The instance is the request ID, so an error a customer pastes into a support ticket leads straight to its log lines.
The rule in kit is that nobody builds error JSON by hand. You abort(404, __('api.orders.not_found')), or throw a typed problem created with php artisan make:api-problem, and the package renders it. That keeps every error in the API the same shape, including the ones you add next year.
Authentication Deep Dive
Registration
curl -X POST http://127.0.0.1:8000/v1/auth/register \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{ "name": "Jane Doe", "email": "jane@example.com", "password": "Password123!", "device_name": "cli" }'The device_name field is used as the token name in Sanctum. This is important. When a user has multiple devices, you can see exactly which device each token belongs to, and revoke them individually. “cli”, “ios”, “android”, “web” are all reasonable values. It’s optional, and a token without one is named api-client.
A successful response looks like this:
{ "data": { "id": "01jb8x2m4k5n6p7q8r9s0t1v2w", "name": "Jane Doe", "email": "jane@example.com", "email_verified_at": null, "created_at": "2026-10-20T12:00:00+00:00", "updated_at": "2026-10-20T12:00:00+00:00" }, "meta": { "token": "1|abc123...", "token_type": "Bearer", "expires_at": "2026-10-20T14:00:00+00:00" }}The user is a plain Laravel API resource. The token lives in meta rather than at the top level, which keeps the resource representation clean and separate from the auth metadata. Registration also queues the verification email.
Notice the id is a ULID, not an integer. Kit uses ULID primary keys for users by default. ULIDs are sortable, URL-safe, and don’t leak information about your user count the way sequential integers do.
Login
curl -X POST http://127.0.0.1:8000/v1/auth/login \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{ "email": "jane@example.com", "password": "Password123!", "device_name": "cli" }'The response shape is identical to registration. This consistency matters for client implementations. Your frontend or mobile app can handle both flows with the same token extraction logic.
Token Abilities
This is one of the most important things kit gets right. By default, Sanctum gives new tokens * ability, which means they can do anything. Kit issues tokens with explicit, named abilities instead. A sign-in token gets the defaults from config/sanctum.php:
'default' => [ 'auth:me', 'auth:logout', 'auth:verification:send', 'auth:tokens:read', 'auth:tokens:create', 'auth:tokens:delete', 'webhooks:read', 'webhooks:write',],Protected routes then check for the required ability before allowing access. A token without auth:me cannot hit the /v1/auth/me endpoint, regardless of whether the token is otherwise valid.
This matters for two reasons. First, if a token is compromised, the blast radius is limited to what that token can actually do. Second, when you audit your tokens, you can see exactly what each one is authorized for. That’s dramatically better for incident response than “this token can do everything.”
When you add your own domain endpoints, you’ll define new abilities and attach them to routes:
Route::get('/orders', ListOrdersController::class) ->middleware('abilities:orders:read') ->name('v1.orders.index');Then list the ability in sanctum.abilities.available, which is every ability a route checks, and in default too if every signed-in client should hold it.
API Keys
A script, an integration or an agent should not sign in with a person’s password. Kit gives them API keys instead: long-lived tokens holding a chosen subset of abilities.
curl -X POST http://127.0.0.1:8000/v1/auth/tokens \ -H "Authorization: Bearer <TOKEN>" \ -H "Content-Type: application/json" \ -d '{"name": "deploy-bot", "abilities": ["auth:me"], "expires_at": "2027-01-01T00:00:00Z"}'The response carries the key in meta.token, and that is the only time its value is returned. Two rules keep this safe. A key can only hold abilities the token creating it already holds, so a narrow key can never mint a broader one. And it expires on the date given, at most API_KEY_MAX_DAYS away, or after API_KEY_DEFAULT_DAYS when you leave the date out.
Logout and Token Revocation
# Revoke the current tokencurl -X POST http://127.0.0.1:8000/v1/auth/logout \ -H "Accept: application/json" \ -H "Authorization: Bearer <TOKEN>"
# List the user's tokens, newest first, a page at a timecurl http://127.0.0.1:8000/v1/auth/tokens \ -H "Accept: application/json" \ -H "Authorization: Bearer <TOKEN>"
# Revoke a specific token by IDcurl -X DELETE http://127.0.0.1:8000/v1/auth/tokens/<TOKEN_ID> \ -H "Accept: application/json" \ -H "Authorization: Bearer <TOKEN>"
# Revoke all tokens (full sign-out across all devices)curl -X DELETE http://127.0.0.1:8000/v1/auth/tokens \ -H "Accept: application/json" \ -H "Authorization: Bearer <TOKEN>"The token list is cursor paginated. The body is the page of tokens, with is_current marking the one making the request, and the next page’s URL is in the Link header as rel="next".
The distinction between revoking one token and revoking all is practical. A user who suspects a single device was compromised can revoke just that token from another device. A user who thinks their account was fully compromised can blow out everything at once.
Email Verification and Password Reset
These flows are often skipped in API starters or implemented in ways that only work for web apps. Kit implements them properly for headless API clients, and sends both emails from the queue so a slow mail server never slows a request.
Email Verification
# Trigger a verification emailcurl -X POST http://127.0.0.1:8000/v1/auth/email/verification-notification \ -H "Accept: application/json" \ -H "Authorization: Bearer <TOKEN>"The verification link uses a signed URL at /v1/auth/email/verify/{id}/{hash}. Your email template points to this endpoint. When the user clicks the link, the API verifies the signature and marks the email as verified. The route constrains id to a ULID and hash to forty hex characters, so a mangled link is a 404 before it reaches the controller.
This is different from the default Laravel behavior where the verification link points to a web route that expects a browser. Kit’s endpoint returns JSON, making it usable from a mobile app or SPA.
Password Reset
# Request a reset email (anti-enumeration: always returns 200)curl -X POST http://127.0.0.1:8000/v1/auth/password/forgot \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{"email": "jane@example.com"}'
# Retrieve the reset payload (for SPA/mobile clients)curl "http://127.0.0.1:8000/v1/auth/password/reset/<TOKEN>?email=jane@example.com"
# Submit the new passwordcurl -X POST http://127.0.0.1:8000/v1/auth/password/reset \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{ "token": "<TOKEN>", "email": "jane@example.com", "password": "NewPassword456!", "password_confirmation": "NewPassword456!" }'The forgot password endpoint always returns a 200 regardless of whether the email exists. This is anti-enumeration behavior. If you return a 404 when the email isn’t found, attackers can determine which email addresses have accounts in your system. Returning 200 consistently closes that information leak, and queueing the email means the response doesn’t take measurably longer when the account does exist.
The GET /v1/auth/password/reset/{token} endpoint is specifically for SPAs and mobile apps. In a web flow, the reset link takes the user to a form. In a headless flow, the client needs to retrieve the token and present its own form. This endpoint returns the token payload that the client needs to submit the form correctly.
The Controller and Payload Pattern
Kit uses a layered pattern that keeps each piece of the request handling narrow and testable. Let’s walk through what a controller actually looks like.
Invokable Controllers
Every controller in kit is invokable. It has a single __invoke method and does not extend a base controller. The controller’s only job is orchestration: take a request, delegate to the right services, return a response.
final class RegisterController{ public function __invoke(RegisterRequest $request, AccessTokens $tokens): JsonResponse { $payload = RegisterPayload::fromRequest($request);
$user = User::create([ 'name' => $payload->name, 'email' => $payload->email, 'password' => $payload->password, ]);
event(new Registered($user));
$token = $tokens->issueSession($user, $payload->deviceName); $expiresAt = $token->accessToken->expires_at;
SecurityAudit::log('auth.register.succeeded', [ 'user_id' => $user->id, 'email_hash' => SecurityAudit::hashEmail($user->email), 'device_name' => $payload->deviceName, 'token_expires_at' => $expiresAt?->toAtomString(), ]);
return UserResource::make($user)->additional([ 'meta' => [ 'token' => $token->plainTextToken, 'token_type' => 'Bearer', 'expires_at' => $expiresAt?->toAtomString(), ], ])->response()->setStatusCode(201); }}Notice there’s no validation logic here, and no token policy. AccessTokens decides which abilities a sign-in token gets and when it expires, so registration and login cannot drift apart. Each of those concerns lives elsewhere. The real controller also carries the Scribe attributes that document it, which you’ll see in the documentation section.
FormRequest Validation
Validation lives in app/Http/Requests/Auth/RegisterRequest.php. This is standard Laravel, but worth noting that kit keeps validation rules close to the request rather than in the controller or a service class.
final class RegisterRequest extends FormRequest{ public function authorize(): bool { return true; }
public function rules(): array { return [ 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique('users', 'email')], 'password' => ['required', 'string', Password::defaults()], 'device_name' => ['nullable', 'string', 'max:255'], ]; }}Form Requests validate the body and the query string, never path parameters. A path parameter is validated by the route’s constraints, whereNumber('token_id') or whereUlid('id'). Validating it in the Form Request would document it as a body field as well as a path one, and generated clients and agents would see two inputs with one name.
Readonly Payload Objects
The payload object is a final readonly class that maps from the validated request into a typed value object. This is the DTO layer.
final readonly class RegisterPayload{ public function __construct( public string $name, public string $email, public string $password, public string $deviceName, ) {}
public static function fromRequest(Request $request): self { $data = self::validatedData($request);
return new self( name: Arr::string($data, 'name'), email: Arr::string($data, 'email'), password: Arr::string($data, 'password'), deviceName: isset($data['device_name']) && '' !== $data['device_name'] ? Arr::string($data, 'device_name') : 'api-client', ); }}The readonly modifier means the payload cannot be mutated after construction. Once the request data is mapped into the payload, it’s immutable. The controller receives a value object with fully typed properties, not a request bag it has to reach into. validatedData() returns the Form Request’s validated data, and Arr::string() throws if a value isn’t a string, which is how kit stays clean at PHPStan’s strictest level without casting.
This pattern keeps the controller clean. It also makes testing easier, because you can construct a payload directly in tests without needing to build an entire HTTP request.
Rate Limiting
Kit configures rate limits in AppServiceProvider rather than in route files. This keeps limit definitions in one place and makes them easy to reference by name.
The limits in the default configuration:
auth-register: 10 requests per minute per IPauth-login: 10 requests per minute, keyed on IP plus emailauth-password: 5 requests per minute, keyed on IP plus emailauth-protected: 60 requests per minute per authenticated user
The login and password limits key on both IP and email. This is important. Keying only on IP allows an attacker to rotate IPs and continue brute forcing a specific account. Keying on IP plus email means that even with IP rotation, the per-account rate is still enforced.
RateLimiter::for('auth-login', fn(Request $request): array => [ Limit::perMinute(10)->by(sprintf('%s|%s', $request->ip(), $request->string('email')->value())),]);Routes then reference these limits by name, with ->middleware('throttle:auth-login'). When you add domain endpoints, define their limits in the same place and reference them the same way. A limited request gets a 429 problem with a Retry-After header.
Idempotency on Critical Writes
Registration, the forgot password request, API key creation and webhook subscriptions support Idempotency-Key. This is a pattern borrowed from payment APIs and it’s genuinely useful for any operation where the client might retry on network failure. It follows the IETF’s idempotency key draft, through api-toolbox.
curl -X POST http://127.0.0.1:8000/v1/auth/register \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: \"8e03978e-40d5-43e8-bc93-6894a57f9324\"" \ -d '{ "name": "Jane Doe", "email": "jane@example.com", "password": "Password123!", "device_name": "cli" }'Send this request twice with the same key and same payload and the second request replays the first response rather than creating a second user. This is the behavior you want when a mobile client doesn’t know whether its first registration attempt succeeded before the connection dropped. The quoted form above is the one the draft specifies; a bare key works too, since that is what most clients send.
If you send the same key with a different payload, you get a 422 problem typed as an idempotency key conflict. The key is locked to the first payload it was used with, which prevents a class of attack where an idempotency key is reused to try different payloads. A retry that arrives while the first request is still running gets a 409 with Retry-After, and a malformed key is a 400.
Localization
The localization model is fully API-first. There’s no user preference stored in the database. The locale is resolved from the Accept-Language header on every request.
curl -X POST http://127.0.0.1:8000/v1/auth/password/forgot \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -H "Accept-Language: es" \ -d '{"email": "unknown@example.com"}'The SetRequestLocale middleware reads this header, resolves it against APP_SUPPORTED_LOCALES, and sets the application locale. The response includes a Content-Language header so clients know which locale was applied.
Translation strings live in lang/en/api.php and lang/es/api.php. Problems that api-toolbox raises itself, such as an idempotency conflict, are translated in lang/vendor/api-toolbox/es/problems.php. Tests fail if a Spanish line is missing for any English one, including after a package upgrade adds a new message. Adding a new locale means adding those files and appending the locale code to APP_SUPPORTED_LOCALES.
The machine-readable parts of the response (status codes, field names, problem types) never change. Only the human-readable strings adapt to the locale. This keeps your API contract stable while making the messages meaningful to users in their own language. Queued emails keep the locale of the request that sent them, so a Spanish sign-up gets a Spanish verification email.
Audit Logging
Security-sensitive actions emit structured log events through App\Support\SecurityAudit. Each one is logged as a security.audit entry with the request context attached.
Actions that emit audit events include registration, login, logout, failed authentication attempts, creating and revoking tokens, email verification, password reset, and webhook subscription changes.
Each event includes the event name and time, the request ID, the IP address, the method and path, and the details that matter for that event, such as the user ID or a hashed version of the email address. That last one lets you correlate events without storing plaintext emails in logs.
In production, you need a logging backend that captures these events with a retention policy. In development, they go to the standard log file. You can tail the log and watch auth events as they happen:
tail -f storage/logs/laravel.log | grep security.auditThe hashed email identifier is worth understanding. Storing plaintext emails in logs creates a privacy and compliance risk. A hash lets you correlate events across a session (did this email address try to log in before registering?) without storing the plaintext value. It’s a SHA-256 of the lowercased address, so the same email always gives the same hash, and you can’t read the address back from the log.
Endpoint Deprecation and Sunset
When you’re retiring an endpoint, you mark its controller with api-toolbox’s attributes:
use JustSteveKing\Toolbox\Deprecation\Attributes\Deprecation;use JustSteveKing\Toolbox\Deprecation\Attributes\Sunset;
#[Deprecation(at: '2030-01-01T00:00:00Z', link: 'https://docs.example.com/deprecations/login-v1')]#[Sunset(at: '2030-07-01T00:00:00Z', link: 'https://docs.example.com/sunset-policy')]final class LoginController{ // ...}Every response from that endpoint then carries the headers:
Deprecation: @1893456000Sunset: Mon, 01 Jul 2030 00:00:00 GMTLink: <https://docs.example.com/deprecations/login-v1>; rel="deprecation"Link: <https://docs.example.com/sunset-policy>; rel="sunset"Deprecation is RFC 9745 and Sunset is RFC 8594, so any client or gateway that understands the standards can act on them. Once the sunset date has passed, RetireSunsetEndpoints answers 410 Gone as a problem instead of passing the request to the controller. The endpoint hard-retires on schedule without requiring a deployment.
This is the right way to evolve an API. Clients get advance notice through headers. API monitoring tools that parse Sunset headers can alert teams when endpoints they depend on are approaching retirement. And when the date arrives, the endpoint shuts down automatically. Headers don’t reach an agent through a tool definition, though, so put the dates in the endpoint’s description as well.
Documentation and Contract Testing
Kit uses Scribe for API documentation with PHP attributes on controllers. This means the docs live with the code that implements the behavior, rather than in a separate document that drifts.
#[Group(name: 'Authentication')]#[Endpoint( title: 'Register', description: 'Create an account and issue its first bearer token. Use it once per new user; send an Idempotency-Key so a retried request cannot create a second account. Returns the user with the token, its type and expiry in meta, and sends a verification email.',)]#[Unauthenticated]#[BodyParam('email', type: 'string', description: 'Unique email address for login.', required: true, example: 'jane@example.com')]#[ResponseFromApiResource(name: UserResource::class, model: User::class, status: 201, description: 'Registration succeeded.')]final class RegisterController{ // ...}Every error status is documented as a problem too, and the descriptions follow a pattern: what the endpoint does, when to use it, and what it returns. That prose is most of what an agent sees when it turns your spec into tools.
Generate the docs:
php artisan scribe:generate --no-interactionThis produces three artifacts:
public/docs/index.html- a browsable docs UIpublic/docs/openapi.yaml- a machine-readable OpenAPI 3.x specpublic/docs/collection.json- a Postman collection
The OpenAPI spec is what the contract tests use. tests/Feature/OpenApiContractTest.php verifies, on every test run:
- Every route under
v1is represented in the spec. - The HTTP status codes returned at runtime are declared in the spec.
- The response bodies and media types at runtime match what the spec documents, including fields that can be null.
- No endpoint is still documented with a TODO.
If you change a response shape without updating the attributes, the test fails. The docs cannot drift from the implementation because the CI pipeline won’t let them.
CI then grades the spec with legible, which scores an OpenAPI file for agent readiness: vague descriptions, inconsistent naming, unhelpful errors, inputs that collide once flattened into a tool. Any warning, or a score under 95, fails the build. .legible.yaml lists the findings accepted on purpose, each with its reason.
Run the full test suite:
composer testCode Quality Tooling
Kit ships with a full quality toolchain configured and ready to use.
composer lint # Pint, the PER preset, in check modecomposer rector:check # Rector, in check modecomposer stan # PHPStan with Larastan, level 10phpstan.neon runs at level 10, the strictest. The Larastan extension adds Laravel-specific type inference, which means PHPStan understands things like model relationships returning HasMany<Token>. At that level a mixed value has to be narrowed rather than cast, which is why kit reads input through typed accessors.
pint.json enforces a consistent code style across the project, on the PER preset. Rector handles PHP and Laravel upgrades, dead code and type declarations, and stays out of formatting entirely: its coding-style set is off. To fix findings, run composer rector and then composer pint, in that order, so Pint always has the last word on style.
CI and Security Automation
Kit includes three GitHub Actions workflows, and they ship with every project created from it.
ci-tests.yml
Runs Pint, Rector, PHPStan, the test suite and legible on every push and pull request. A second job builds the production image, boots it with production settings and makes requests to it, which catches the bugs tests can’t see because tests always have dev dependencies installed. No merge without green checks.
dependency-updates.yml
Runs composer update weekly, on Monday mornings, and opens or updates a pull request titled bot: dependency updates. Updates go through Laravel Vet: vet.json records every package somebody has read, composer install fails on anything else, and releases younger than seven days are held back so a bad one has time to be found. Laravel’s own packages are trusted by policy, so an update that only touches them records its own trust and needs no review. Anything else waits for a person, or an agent, to read the changes and commit vet.json.
security-gate.yml
Runs composer audit against the PHP Security Advisories database and fails the build on high or critical severity advisories. It also runs Gitleaks to scan for accidentally committed secrets. A committed API key or database password is one of the most common and most severe security incidents in web development. Gitleaks catches these before they reach the remote.
Adding a New Endpoint
When you start extending kit with your own domain, there’s a consistent process to follow, and a generator that does most of it. Let’s say you’re building a simple orders API.
1. Scaffold it
php artisan make:endpoint Orders/ListOrders --method=GET --uri=/ordersThis writes the controller with its Scribe attributes and documented problem responses, a Form Request, a payload object, and a Pest test whose authentication and ability cases already pass. It appends the route with auth:sanctum, a rate limit, an orders:read ability and a v1.orders.index name, and prints the steps it leaves to you.
What it can’t know, it leaves visible. The controller answers 501 Not Implemented, and the description is three TODO sentences that fail CI until you replace them. An endpoint can’t ship half-written.
2. Write the Form Request
final class ListOrdersRequest extends FormRequest{ public function authorize(): bool { return true; }
public function rules(): array { return [ 'status' => ['sometimes', 'string', 'in:pending,fulfilled,cancelled'], ]; }
/** * Read by Scribe, so this GET's rules are documented as query parameters, not a body. */ public function queryParameters(): array { return [ 'status' => ['description' => 'Only orders in this status.', 'example' => 'pending'], ]; }}The generator adds the queryParameters() method to a GET’s Form Request, because without it Scribe documents the rules as a request body.
3. Fill in the payload
final readonly class ListOrdersPayload{ public function __construct( public ?string $status, ) {}
public static function fromRequest(Request $request): self { $data = self::validatedData($request);
return new self( status: isset($data['status']) ? Arr::string($data, 'status') : null, ); }}4. Implement the controller
final class ListOrdersController{ public function __invoke(ListOrdersRequest $request, #[CurrentUser] User $user): CursorPage { $payload = ListOrdersPayload::fromRequest($request);
return CursorPage::of( $user->orders() ->when($payload->status, fn($query) => $query->where('status', $payload->status)) ->orderByDesc('id') ->cursorPaginate(25), OrderResource::class, ); }}CursorPage comes from api-toolbox. The body is the page of orders, and the navigation goes in Link headers, the same way the token list works. Cursor pagination doesn’t skip or repeat rows when new ones arrive between pages, which offset pagination does.
5. Register the ability
Add orders:read to sanctum.abilities.available in config/sanctum.php, and to default if every signed-in client should hold it. If it’s missing from available, nobody can create an API key holding it.
6. Finish the tests
The generated test already proves the route needs a token and the ability. Replace the todo() with the behavior:
it('lists the orders of the authenticated user', function (): void { $user = User::factory()->create(); Order::factory()->count(3)->for($user)->create();
$token = $user->createToken('test', ['orders:read'])->plainTextToken;
$this->withToken($token) ->getJson('/v1/orders') ->assertOk() ->assertJsonCount(3) ->assertLinkHeader('first');});7. Regenerate docs and verify
php artisan scribe:generate --no-interactioncomposer stancomposer testKit’s AGENTS.md carries this same recipe for coding agents, so an agent asked to add an endpoint runs the generator and fills in the TODOs, rather than inventing a structure of its own.
Webhooks
Webhooks are where an API’s consumers find out something changed without polling. Kit lets them subscribe their own https endpoints under /v1/webhooks, then delivers each event as a CloudEvent, signed with RFC 9421 using that subscription’s own secret. Every attempt is recorded, retries follow a published schedule, a failing endpoint is eventually disabled with a reason, and a consumer can list deliveries and redeliver one.
Adding an event of your own is a class implementing App\Webhooks\WebhookEvent and one line to dispatch it:
app(Webhooks::class)->dispatch($order->user, new OrderPlaced($order));There is a lot more in the delivery side, including the protection against subscription URLs pointing at your own network, and the companion article walks through it.
Before You Go to Production
App\Support\ProductionSecurityChecks runs at application boot in production and fails immediately if any of these conditions aren’t met:
APP_DEBUGmust befalseSECURITY_FORCE_HTTPSmust betrueAPP_URLmust start withhttps://- CORS origins cannot be
* TRUSTED_HOSTSmust be configured
These aren’t warnings. The application will not boot if the checks fail. That’s intentional. A misconfigured production environment that silently boots is worse than one that refuses to start.
Kit’s Dockerfile builds the production image: Octane on FrankenPHP, running as a non-root user on port 8000, with OPcache tuned for production and a health check on /up. The same image runs your queue worker and the scheduler:
docker run -p 8000:8000 --env-file .env.production my-apidocker run --env-file .env.production my-api php artisan queue:workdocker run --env-file .env.production my-api php artisan schedule:workPut TLS in front of it, forward X-Forwarded-Proto, and run migrations as a release step. Octane keeps the application in memory between requests, so keep request state out of singletons and static properties; kit and api-toolbox already do.
Beyond the automated checks, confirm these before launch:
Token expiration and abilities match your threat model. Sign-in tokens should be short-lived, and anything long-lived should be an API key with the minimum abilities it needs.
Throttle limits match your expected client behavior. The defaults are conservative. If your API will serve high-traffic clients, adjust the limits in AppServiceProvider before launch.
security.audit events are captured with a retention policy. These events are only useful if you can search them. Set up a log aggregation backend (Papertrail, Logtail, CloudWatch Logs) before launch and verify events are being captured.
Your sunset policy is part of your release process. If you’re shipping v1 endpoints today, decide now how you’ll communicate deprecation to clients. The tooling is there. Use it.
Who This Is For
Kit is worth adopting if you want a serious API baseline with auth and security already decided, contract-driven docs and testing from day one, and a clear path to versioning and endpoint lifecycle management. It’s a particularly good fit when agents will write a fair share of the code, because the conventions are written down for them and enforced by CI.
If you want minimal conventions or primarily need a server-rendered UI stack, look elsewhere. This starter makes a lot of decisions on purpose, and fighting those decisions will slow you down.
The whole point is that you shouldn’t have to make those decisions again. Run the installer, configure the env, and start building the domain that actually matters.
Related
More in API Design
You Can't Deprecate a Field With a Header
RFC 9745's Deprecation header covers whole resources, so a field changing shape has no HTTP signal. Where that deprecation goes instead, and where the header is exactly right.
Oct 2026 · 7 min read
API DesignBoth Ends of the Wire
I built a small CRM API on api-toolbox and its SDK on sdk-toolbox, in one repository, tested against each other. My own two libraries disagreed within a day.
Oct 2026 · 8 min read
API DesignFixing a Bug Can Be a Breaking Change
Your consumers built against your behaviour, not your documentation. Correcting a bug they worked around is an outage you shipped on purpose.
Oct 2026 · 7 min read
Same subject
In other formats
kit
Opinionated Laravel API starter kit: token authentication, standards-compliant HTTP via api-toolbox, and contract-tested OpenAPI docs.
API Design
VideoLets' Build: API Kit
With the release of community starter kit support in the Laravel installer, it's high time we had an API first starter kit. Join me as I start the process of building "API Kit" an API Starter Kit for
API Design
TalkBuilding High-Performance APIs with Laravel's Built-in Tools
API Design