Skip to main content
WritingVideosSpeakingCodeAbout

API Design

A Laravel API Starter Kit That Keeps Its Promises

Starter kits are right on the day you install them and drift from then on. Kit now checks its own promises in CI, so the docs, spec, errors and dependencies stay true.

Oct 202612 min read

Every starter kit is correct on the day you install it. The README promises consistent errors, documentation that matches the code and sensible security defaults, and for that first afternoon all of it is true.

Then you start building. Someone adds an endpoint and returns an error the way they did on their last project. Someone else changes a response and forgets the docs. A dependency update lands on a Friday. Nobody broke the promises on purpose; nothing was checking them, so they quietly stopped being true.

Coding agents speed that up. An agent reads the codebase in front of it and copies what it finds, so the first inconsistent endpoint becomes the template for the next ten.

Back in February I wrote a long tutorial on kit, my Laravel API starter kit. Since then it has been rebuilt around one idea: every promise the README makes should be something CI can fail on. This article is about what that looks like, why each check is there, and whether it is worth starting your next API with.

Getting it

Kit is on Packagist, so the Laravel installer can start a project from it:

Terminal window
laravel new my-api --using=juststeveking/kit

That gives you a working API on SQLite with the app key set, the migrations run and the test suite green. The base path is versioned with no /api prefix, so your first request goes to http://127.0.0.1:8000/v1/auth/register.

The day-one feature list is the part every starter kit has, so I will keep it short. Laravel 13 on PHP 8.5. Sanctum session tokens for sign-in, and scoped API keys for scripts, integrations and agents. Errors as RFC 9457 problem details. Idempotency keys on writes, cursor pagination with Link headers, ETags, request IDs and trace context, all from api-toolbox. Queued emails, English and Spanish responses, and a production image on Octane and FrankenPHP.

All useful, none of it the interesting part. The interesting part is what stops it rotting.

Documentation that cannot drift

Kit documents its API with Scribe and checks the generated OpenAPI spec against the running application. Every v1 route has to be in the spec, and real responses have to match their documented status, body and media type.

That last part only works if the test is asked the right questions, which I learned the hard way. The contract test used to compare the spec with routes under v1/auth only, while Scribe documents everything under v1. So the first endpoint a project added outside auth failed the test as “extra in the spec”, which is exactly backwards. It now checks every v1 route.

A code review caught the other gap. Scribe infers a property’s type from its example value, so a field documented with a null example becomes a nullable string, whatever it really is. The webhook subscription’s allowed_rate is an integer, and the spec said string. Generated clients would have rejected the real response.

The fix had two halves. Fields now declare their type with Scribe’s #[ResponseField], and a small OpenAPI generator applies those declarations everywhere, including inside arrays, where Scribe ignores them. Then the contract test gained authenticated checks with real data, a fresh subscription full of nulls and one with a granted rate, so the next type mismatch fails the build instead of a client.

A spec agents can actually use

An agent does not read your documentation site. It reads your OpenAPI file and turns each operation into a tool definition, and a lot gets lost on the way. Response headers, status codes and most of your carefully chosen metadata never reach the model. The description survives, and so do the parameter names.

Kit runs legible in CI, which scores a spec for exactly this. The gate is strict: any warning fails, and so does a score under 95.

When I first ran it against kit, it scored 90 with three errors, and the errors were mine. The Form Requests for routes like DELETE /v1/auth/tokens/{token_id} merged the route parameter into validation, so Scribe documented token_id as a path parameter and a body field. Flatten that into a single tool input and the two names collide.

The fix was to stop validating path parameters as input. They are checked by route constraints now:

Route::delete('/auth/tokens/{token_id}', DeleteTokenController::class)
->whereNumber('token_id')
->middleware('abilities:auth:tokens:delete')
->name('v1.auth.tokens.destroy');

A malformed ID is now a 404 rather than a 422, which is the more honest answer anyway: no resource lives at that address. Every operation description was rewritten to say what it does, when to use it and what it returns, because that prose is all a model gets. The spec scores 100 now, with the handful of findings accepted on purpose written down, with reasons, in .legible.yaml.

Errors that look like each other

Every error kit returns is application/problem+json, with a type that resolves to a page in the apiguide.dev catalogue:

{
"type": "https://apiguide.dev/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "No autenticado.",
"instance": "urn:uuid:0199e7f4-5d2a-7c3e-9b1a-4f2d8e6c1a07"
}

The detail is translated, the type never is, so clients branch on something stable. 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.projects.not_found')), or throw a typed problem from php artisan make:api-problem, and the package renders it. That rule is easy to write in a README and easy to forget on a deadline, which is where the next check comes in.

Endpoints that cannot ship half-built

Kit has a generator for endpoints:

Terminal window
php artisan make:endpoint Projects/CreateProject --method=POST --uri=/projects

It writes most of what an endpoint needs in kit: the route with authentication, a rate limit, an ability and a name, the Form Request, a payload object, the controller with its Scribe attributes and documented problem responses, and a Pest test whose authentication and ability cases pass straight away.

What it cannot know, it leaves visible. The controller answers 501 Not Implemented. Path parameters get a comment asking for a constraint. The description is three TODO sentences, and a contract test fails the build while the spec still says TODO anywhere, so an endpoint cannot ship half-documented. Scaffold two at once and legible joins in, since they start with the same description.

This matters more for agents than for people. An agent asked to add an endpoint will happily invent its own structure. Asked to run a generator and fill in the TODOs, it produces endpoints that look like the rest of the codebase. Kit’s AGENTS.md tells it to do exactly that.

Dependencies somebody has read

composer audit only knows about advisories that have been published. A compromised release that nobody has noticed yet passes it, and a weekly dependency bot will cheerfully open a pull request for it.

Kit uses Laravel Vet, which records every package somebody has read in vet.json and fails composer install on anything else, including the same version republished with different files. It also holds back any release younger than seven days, which gives a bad release time to be found and pulled before it reaches you.

Seven days is not free. roave/security-advisories publishes a new version whenever an advisory lands, so with the hold in place it simply could not be installed, and holding it back would delay the very advisories it exists to block. It is excluded, along with Laravel’s own packages, which kit trusts by policy. When a weekly update only touches laravel/*, the bot records the trust itself and the pull request needs no review. Anything else waits for a person, or an agent, to read the changes and commit vet.json.

Rector, without a style war

Kit runs Rector for PHP and Laravel upgrades, dead code and type declarations, and Pint with the PER preset for formatting. Two tools that both rewrite code will argue unless one of them is told to stay out of the other’s way.

So Rector’s coding-style set is off, Rector runs first and Pint second, and CI checks that Rector has nothing left to say after both. They settle in one pass.

The interesting part was what Rector’s Laravel upgrade set wanted to do on its first run. It renamed $subscription->events to $subscription->dispatchesEvents, applying an old Eloquent property rename to a model attribute that happened to share the name. It also renamed a private retryAfter() method on a queued job to backoff(). Laravel’s queue looks for a backoff() method on a job and calls it itself, so the worker would have fallen over trying to call a private method. The first rule is skipped, with the reason written in rector.php. For the second I renamed kit’s method instead, so the rule stays on for real jobs. Read what Rector proposes before you trust it. It is very good, and it does not know your models.

A production image that is tested like code

The Dockerfile runs Octane on FrankenPHP as a non-root user, with OPcache tuned for production and a health check, and the same image runs a queue worker when you give it a command. CI builds it on every push, boots it with production settings and makes requests to it.

Building it the first time found a bug that had been in kit all along. config/scribe.php referenced Scribe’s classes, Scribe is a dev dependency, and Laravel loads every config file, so every artisan command in a production install failed. No test could see it, because tests always have dev dependencies installed. Booting the image in CI is how that class of bug gets caught now.

The same smoke test found two bugs in api-toolbox itself. Under Octane each request is served from a sandboxed copy of the application, and two closures in the package had captured the application from boot. Problems lost their instance and outgoing requests lost their trace. That is fixed in the package now, and the image check asserts that each problem names its own request.

Webhooks, done the long way

Webhooks are the part of an API that rarely gets designed, so they are where kit goes furthest. Consumers subscribe their own endpoints through the API:

Terminal window
curl -X POST http://127.0.0.1:8000/v1/webhooks \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"url": "https://hooks.example.com/kit", "events": ["*"]}'

The response carries the signing secret once. Every delivery is a CloudEvents event in structured mode, so any CloudEvents SDK can parse it:

{
"specversion": "1.0",
"id": "01jb8x3a1b2c3d4e5f6g7h8j9k",
"source": "https://api.example.com/v1",
"type": "project.created",
"subject": "01jb8x2m4k5n6p7q8r9s0t1v2w",
"time": "2026-10-20T12:01:00+00:00",
"datacontenttype": "application/json",
"data": {}
}

Deliveries are signed with RFC 9421 HTTP Message Signatures, each subscription with its own secret, so a Laravel receiver using api-toolbox verifies them with one middleware. The event ID goes out as an Idempotency-Key, so a receiver that deduplicates on it ignores retries for free.

Delivery state and delivery history are separate tables. One row says where a delivery stands; another row exists for every attempt. A delivery that failed five times and then succeeded should not read as though it always worked, and an attempt that never got a response keeps a null status rather than a 0, because “we never heard back” and “it said 500” need different fixes.

Retries follow a published schedule, and the job schedules them itself rather than leaning on Laravel’s $backoff, which repeats its last value once the list runs out. A 410 Gone disables the subscription, a 429 waits at least as long as its Retry-After, and a 415 fails without retrying, since the format is not going to change. That is the CloudEvents webhook specification, which also describes an abuse-protection handshake. Kit implements that too, with its consent callback and allowed rate, behind a flag that is off by default, because every receiver then has to support it.

Subscription URLs are user input, which makes them a server-side request forgery risk. They have to be https and must not resolve to a private, loopback or reserved address. That is checked when you subscribe and again on every send, with the connection pinned to the address that was checked and redirects refused.

Adding your own event is a class and a line:

final readonly class ProjectCreated implements WebhookEvent
{
public function __construct(private Project $project) {}
public function type(): string
{
return 'project.created';
}
public function subject(): string
{
return $this->project->id;
}
public function data(): array
{
return ['id' => $this->project->id, 'name' => $this->project->name];
}
}
app(Webhooks::class)->dispatch($project->owner, new ProjectCreated($project));

Deliveries queue after the surrounding transaction commits, so a subscriber is never told about a row it cannot find yet.

Who should use it

Use kit if you are starting an API-only Laravel application and want the decisions above made for you: problem details, versioned routes, invokable controllers with payload objects, a strict spec and a strict dependency policy. It is a good fit when agents will be writing a fair share of the code, because the conventions are written down in AGENTS.md and enforced by CI rather than hoped for.

Do not use it for a full-stack application with Inertia or Livewire. Laravel’s own starter kits are better at that, and kit has no frontend at all. Think twice if your organisation already has an error format every client depends on; problem details is the right answer, but migrating a fleet of clients is its own project. And if the strictness sounds tiring, it will be. A legible gate that fails on one weak description is annoying on the day it fails. It is a lot less annoying than an agent building twenty tools on top of it.

The checks are the point. Anyone can copy kit’s files into a project. What you get by starting from kit is the set of things that will tell you, in CI, the day those files stop being true.

Kit is on GitHub and on Packagist. The README covers every feature in detail, and AGENTS.md is the short version of how to build on it, for you and for whatever agent you work with.

Related

More in API Design

All posts →

Same subject

In other formats

All of API Design →