Package
kit
Opinionated Laravel API starter kit: token authentication, standards-compliant HTTP via api-toolbox, and contract-tested OpenAPI docs.
v0.1.5115 stars
Opinionated Laravel API starter kit for token-based authentication, with strong defaults around API design, HTTP standards, documentation, testing and security.
Highlights
- PHP
8.5+ Laravel13 - SQLite-first local development (
DB_CONNECTION=sqlite) - Sanctum token authentication: session tokens on sign-in, and scoped, long-lived API keys for scripts, integrations and agents
- Versioned API routes with no
/apiprefix (/v1/...) - Invokable controllers only
- Form Request validation + DTO-style request payload objects
- Standards-compliant HTTP through api-toolbox:
- Every error is RFC 9457 problem details, typed from the apiguide.dev error catalogue
Idempotency-Keyreplay and conflict handling on critical writesDeprecationandSunsetheaders from controller attributes, and410 Goneafter sunset- Cursor pagination with RFC 8288
Linkheaders - ETags and
304 Not Modified - Request IDs and W3C trace context
- An RFC 9727 API catalog at
/.well-known/api-catalog
- API localization via
Accept-Language+Content-Language - Scribe (attribute-based) API docs + OpenAPI generation
- OpenAPI contract tests to keep docs and runtime behaviour in sync, and a legible gate that keeps the spec agent-ready
php artisan make:endpointscaffolds an endpoint the way the rest of Kit is built- A production image: Laravel Octane on FrankenPHP, with the same image as the queue worker
- Outbound webhooks: consumers subscribe their own endpoints; deliveries are CloudEvents, signed (RFC 9421), retried on a published schedule, recorded attempt by attempt, and can be redelivered
- GitHub Actions for style, static analysis, tests, security audits and weekly, Vet-audited dependency update PRs
- An
AGENTS.mdthat tells coding agents, and you, how to build on Kit the way it is built
Tech Stack
- Laravel Framework:
^13.30 - PHP:
^8.5 - Auth:
laravel/sanctum - HTTP standards:
juststeveking/api-toolbox - Docs/OpenAPI:
knuckleswtf/scribe(attributes, not docblocks) - Test Runner: Pest + Laravel test tooling
- Static Analysis / Quality: PHPStan (Larastan, level 10), Pint, Rector
Quick Start
1) Start your project
Kit is a starting point rather than a package, so take a copy and give it its own history:
git clone https://github.com/JustSteveKing/kit.git my-apicd my-apirm -rf .git && git init2) Install dependencies
composer install3) Bootstrap environment
cp .env.example .envphp artisan key:generatetouch database/database.sqlitephp artisan migrateOr run the bundled setup script:
composer run setup4) Run the API
php artisan servephp artisan queue:work # in a second terminal: verification and reset emails are queuedMail goes to the log (MAIL_MAILER=log) until you configure a mailer.
API base path is versioned and has no global /api prefix:
http://127.0.0.1:8000/v1/...
API Routing
Routing is intentionally split:
routes/api/routes.phpfor top-level API version groupingroutes/api/v1.phpfor V1 endpoint declarations
Framework routing is configured with apiPrefix: '' in bootstrap/app.php, so your URLs stay clean.
Auth Endpoints (V1)
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /v1/auth/register | No | Register and issue token |
| POST | /v1/auth/login | No | Login and issue token |
| GET | /v1/auth/me | Bearer | Current authenticated user |
| POST | /v1/auth/logout | Bearer | Revoke current token |
| GET | /v1/auth/tokens | Bearer | List the user’s tokens, cursor paginated |
| POST | /v1/auth/tokens | Bearer | Create an API key with chosen abilities |
| DELETE | /v1/auth/tokens | Bearer | Revoke every token |
| DELETE | /v1/auth/tokens/{token_id} | Bearer | Revoke one token |
| POST | /v1/auth/email/verification-notification | Bearer | Send/resend verification email |
| GET | /v1/auth/email/verify/{id}/{hash} | Signed URL | Verify email |
| POST | /v1/auth/password/forgot | No | Request reset email (anti-enumeration response) |
| GET | /v1/auth/password/reset/{token} | No | Return reset payload for API clients |
| POST | /v1/auth/password/reset | No | Reset password |
First Requests (cURL)
Register:
curl -X POST http://127.0.0.1:8000/v1/auth/register \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "name": "Jane Doe", "email": "jane@example.com", "password": "Password123!", "device_name": "cli" }'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" }'Use token on protected route:
curl http://127.0.0.1:8000/v1/auth/me \ -H "Accept: application/json" \ -H "Authorization: Bearer <TOKEN>"Localized error (Spanish):
curl -i http://127.0.0.1:8000/v1/auth/me \ -H "Accept-Language: es"HTTP/1.1 401 UnauthorizedContent-Type: application/problem+jsonContent-Language: esWWW-Authenticate: Bearer
{"type":"https://apiguide.dev/errors/unauthorized","title":"Unauthorized","status":401,"detail":"No autenticado.","instance":"urn:uuid:0199e7f4-5d2a-7c3e-9b1a-4f2d8e6c1a07"}Response Design
- Resources are plain Laravel API resources (
application/json), wrapped indata - Errors are RFC 9457 problem details (
application/problem+json), rendered by api-toolbox:typeis an apiguide.dev URI that identifies the error, so clients branch on it rather than on message textdetailis the human-readable message, translated where Kit raised it- validation errors keep Laravel’s field messages under
errors instanceis the request ID, so a reported error leads straight to its log lines
- To raise a typed error of your own, see
api-toolbox’s Problem Details docs
(
php artisan make:api-problem) - Write endpoints only accept
application/jsonbodies (consumes:application/json); anything else is a415that names the supported type
Localization
Locale resolution is API-first:
- Middleware reads
Accept-Language - Locale is resolved against
APP_SUPPORTED_LOCALES - Response includes
Content-Language - Unsupported locales fall back to
APP_FALLBACK_LOCALE
Every problem’s detail is translated; its type and title stay the same in every language,
so clients key on the type:
- Messages Kit raises itself, and Laravel’s validation messages:
lang/{en,es}/api.phpandlang/{en,es}/validation.php - Problems api-toolbox raises, such as an idempotency conflict or an unsupported media type:
English ships with the package, Spanish is in
lang/vendor/api-toolbox/es/problems.php. A test fails if a package upgrade adds a line the Spanish file does not have
To add a language, add it to APP_SUPPORTED_LOCALES and give it each of those files.
Relevant config/env:
APP_LOCALEAPP_FALLBACK_LOCALEAPP_SUPPORTED_LOCALES(default:en,es)SANCTUM_EXPIRATION: session token lifetime (default:120minutes)API_KEY_DEFAULT_DAYS/API_KEY_MAX_DAYS: API key lifetime (defaults:90/365days)
Architecture Conventions
- Controllers are invokable, final, and do not extend a base controller
(
app/Http/Controllers/Api/V1/{Domain}) - Request validation lives in Form Requests (
app/Http/Requests/{Domain}) - DTO payload objects live in
app/Http/Payloads/V1(final, readonly) - Entity output lives in plain API resources (
app/Http/Resources) - HTTP standards are switched on in
config/api-toolbox.php, one feature per standard
Adding Endpoints
php artisan make:endpoint Projects/CreateProject --method=POST --uri=/projectsThe generator writes the controller with its Scribe attributes and documented problems, the Form
Request, the payload, a Pest test whose authentication and ability cases pass straight away, and
the route with auth, rate limit, ability and name (plus idempotent on a POST). It leaves what it
cannot know as TODO: the controller answers 501, and CI fails while the documentation still says
TODO, so an endpoint cannot ship half written. Change what it generates in stubs/endpoint/.
AGENTS.md covers each piece and the steps the generator prints for
you: the ability in config/sanctum.php, the contract test’s runtime check, and the docs.
API Keys
Sign-in gives a session token with the default abilities. For a script, an integration or an
agent, create an API key instead, from any token that holds auth:tokens:create:
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"}'- A key can only hold abilities the token creating it holds, out of those a route checks
(
sanctum.abilities.available), so a narrow key can never mint a broader one. - It expires on
expires_at, at mostAPI_KEY_MAX_DAYSaway, or afterAPI_KEY_DEFAULT_DAYS. meta.tokenin the response is the only time its value is returned. Revoke it like any token.- Send an
Idempotency-Keyand a retried request returns the same key rather than a second one.
Security Defaults
- ULID primary keys for users
- Password hashing via model casts
- Email verification required model contract (
MustVerifyEmail) - Every token carries its own expiry: session tokens after
SANCTUM_EXPIRATIONminutes, API keys on the date they were given - Rate limits configured in
AppServiceProvider:auth-register: 10/minute per IPauth-login: 10/minute per IP + emailauth-password: 5/minute per IP + emailauth-protected: 60/minute per authenticated user
- Verification endpoints use signed URLs and throttling
- Write endpoints enforce JSON payloads (
application/json) - API responses include baseline hardening headers (
nosniff,DENY,no-referrer) - Every request gets an
X-Request-Id(kept from a proxy when well-formed, otherwise a UUIDv7), and joins or starts a W3C trace (traceparent); both land in the log context - Security-sensitive auth/token actions emit structured
security.auditlog events - Configurable transport hardening for HTTPS enforcement, HSTS, trusted proxies/hosts, and strict CORS origins
- Dependencies are audited with Laravel Vet: nothing is installed until someone has read it, and no release younger than 7 days is installed at all
Dependency Audit (Vet)
composer audit only knows about published advisories, so a freshly compromised release passes
it. Laravel Vet (beta) covers that gap as a Composer plugin:
vet.jsonrecords the version and file hash of every package someone has read.composer installfails on anything else, including the same version re-published with different files.minimum-release-age: 7keeps every release younger than a week out ofcomposer update, so a bad release has time to be found and pulled.juststeveking/*androave/security-advisoriesare excluded: the first are first-party, the second has to be current to be any use.
After composer update, review what changed and record what you trust:
./vendor/bin/vet # in a terminal: read each change, or hand them to your coding agentLaravel’s own packages (laravel/*) are trusted by policy: they skip the 7-day hold, and when
they are the only packages an update changed, ./vendor/bin/vet --fresh records them without a
review. The weekly bot does this itself, so a Laravel-only update PR needs no review.
Commit vet.json with composer.lock. If you build your own project from Kit, vet.json is the
trust Kit recorded; run ./vendor/bin/vet --fresh to take it over as your own, or remove
laravel/vet if you do not want the gate.
Idempotency
POST /v1/auth/register and POST /v1/auth/password/forgot honour an Idempotency-Key header,
following draft-ietf-httpapi-idempotency-key-header:
| Situation | Response |
|---|---|
| Retry with the same key and payload | The original response, replayed without running again |
| Same key, different payload | 422, https://apiguide.dev/errors/idempotency-key-conflict |
| Retry while the first request is still running | 409 with Retry-After |
| Malformed key | 400 |
Keys are scoped to the user or client address and kept for IDEMPOTENCY_TTL seconds (default
86400). Add ->middleware('idempotent') to other writes, or idempotent:required to make the key
mandatory.
Deprecation and Sunset
Mark a controller, or one action, 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{ // ...}Behaviour:
- Adds
Deprecation(RFC 9745) andSunset(RFC 8594) headers, withrel="deprecation"andrel="sunset"links - Once the sunset date has passed,
RetireSunsetEndpointsanswers410 Goneas problem details, still carrying both headers
Pagination and Caching
GET /v1/auth/tokensreturns a page of tokens as the body, with navigation inLinkheaders (rel="next",rel="prev",rel="first"). A cursor that cannot be decoded is a400GET /v1/auth/mesends anETag; repeat the request withIf-None-Matchand an unchanged user gets304 Not Modified
Webhooks
API consumers subscribe their own https endpoints to events, behind the webhooks:read and
webhooks:write abilities:
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/webhooks | List subscriptions |
| POST | /v1/webhooks | Subscribe a URL to events; the secret is returned once |
| GET | /v1/webhooks/{webhook} | Show a subscription, and why it was disabled if it was |
| PATCH | /v1/webhooks/{webhook} | Change the URL, events or description, or enable and disable it |
| DELETE | /v1/webhooks/{webhook} | Delete a subscription and its history |
| POST | /v1/webhooks/{webhook}/secret | Rotate the signing secret |
| POST | /v1/webhooks/{webhook}/ping | Send a webhook.ping to test the endpoint |
| POST | /v1/webhooks/{webhook}/validate | Ask the endpoint for consent again (when validation is on) |
| GET, POST | /v1/webhooks/{webhook}/consent | The signed consent link sent to the endpoint; no token needed |
| GET | /v1/webhooks/{webhook}/deliveries | Deliveries, each with every attempt |
| POST | /v1/webhooks/{webhook}/deliveries/{delivery}/redeliver | Send an event again |
Every delivery is a CloudEvents 1.0 event in structured
content mode, POSTed as application/cloudevents+json:
{ "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": {}}source is WEBHOOKS_SOURCE (default APP_URL/v1), subject is what the event is about and is
left out when there is none, and any CloudEvents SDK can parse the body. Delivery follows the
CloudEvents webhook specification:
https only, redirects never followed, and every request carries WebHook-Request-Origin
(WEBHOOKS_ORIGIN, default APP_URL’s host).
- Signed with RFC 9421 HTTP Message Signatures
(
Signature,Signature-Input, withkeyidset to the subscription ID), using HMAC-SHA256 and the subscription’s secret, over the method, URI,Content-Typeand aContent-Digestof the body. A Laravel receiver with api-toolbox verifies it with thesignature.verifymiddleware. - Deduplicable: the event ID is sent as
Idempotency-Key, and is the same on every retry and redelivery. - Retried after 10s, 1m, 5m, 30m, 2h and 6h (
webhooks.backoff), then marked failed. Any 2xx is success. A429is retried no sooner than itsRetry-After; a410 Gonestops deliveries and disables the subscription; a415fails without retrying, since the format will not change. A subscription is disabled, with a reason, after 10 deliveries in a row fail (WEBHOOKS_DISABLE_AFTER); re-enable it withPATCH {"enabled": true}. - Consented to, when
WEBHOOKS_VALIDATE_ENDPOINTS=true: a new or moved subscription receives nothing until its endpoint answers anOPTIONSrequest carryingWebHook-Request-Originwith a matchingWebHook-Allowed-Origin(or*), the CloudEvents handshake. It proves the URL’s owner wants the events. Off by default, since every receiver then has to implement it. A receiver that cannot answer straight away, or whose administrator finds the request in a log, consents later through theWebHook-Request-CallbackURL sent with it: a signed link, valid forWEBHOOKS_CALLBACK_HOURS(72), opened with GET or POSTed to, and refused if the subscription’s URL has changed since. - Rate-limited as the endpoint asks: an endpoint grants a rate with
WebHook-Allowed-Rate(deliveries a minute, or*for none), in its handshake response or its consent callback, and kit stays within it; a delivery over the rate waits, without spending a retry. SetWEBHOOKS_REQUEST_RATEto ask for a rate withWebHook-Request-Rate; that rate applies when an endpoint consents without granting one. Limiting needs a queue that can delay, notsync. - Recorded: each attempt keeps its status, a response excerpt, its duration and any error. A
nullstatus means no response arrived, as distinct from an error status. - Safe to point at: URLs must be https and must not resolve to a private, loopback or reserved
address, checked on subscribe and on every send. Loosen this locally with
WEBHOOKS_REQUIRE_HTTPS=falseandWEBHOOKS_ALLOW_PRIVATE_NETWORKS=true. - Pruned: events and their deliveries are kept for 30 days (
WEBHOOKS_RETENTION_DAYS), by a dailymodel:prune, so run the scheduler (php artisan schedule:work) as well as a queue worker.
Kit ships the machinery and the ping; AGENTS.md shows how to add your own events.
Deployment
The Dockerfile builds a production image: Laravel Octane on FrankenPHP, PHP 8.5, running as a
non-root user on port 8000, with OPcache tuned for production and a health check on /up.
docker build -t my-api .docker run -p 8000:8000 --env-file .env.production my-api # the APIdocker run --env-file .env.production my-api php artisan queue:work # a queue workerdocker run --env-file .env.production my-api php artisan schedule:work # the schedulerAt start the container caches config, routes and events from its environment. Production refuses
to boot without APP_KEY, an https:// APP_URL, SECURITY_FORCE_HTTPS=true, TRUSTED_HOSTS
and explicit CORS_ALLOWED_ORIGINS; put TLS in front of it and forward X-Forwarded-Proto. Run
migrations as a release step (php artisan migrate --force). CI builds the image and boots it on
every push.
API Documentation (Scribe + OpenAPI)
Scribe is configured for this no-prefix API shape:
- Route matching uses
v1/*prefixes (config/scribe.php) - Endpoints are documented via PHP attributes
- Error responses are documented as
application/problem+json(App\Support\ProblemDetailsOpenApiGenerator), and204s with no content (App\Support\NoContentOpenApiGenerator) - Path parameters keep the names the routes give them, rather than Scribe’s guessed
{id} - OpenAPI output is generated to
public/docs/openapi.yaml, and advertised from the API catalog at/.well-known/api-catalog
Generate docs/spec:
php artisan scribe:generate --no-interactionlegible scores the spec for agent readiness: what will
trip up an LLM calling the API as tools. .legible.yaml fails CI on any warning or a score under
95, and lists the findings accepted on purpose with the reason.
Generated artifacts:
public/docs/index.htmlpublic/docs/openapi.yamlpublic/docs/collection.json
Testing & Quality
Run test suite:
php artisan testOr use composer script:
composer testOther quality commands:
composer lint # Pint, PER preset, in check modecomposer rector:check # Rector, in check modecomposer stan # PHPStan, level 10To apply them, run composer rector and then composer pint, in that order. Rector changes code
(PHP and Laravel upgrades, dead code, type declarations) and never formatting: Pint’s PER preset
owns style, so Rector’s coding-style set is off, and Pint has the last word on anything Rector
writes. rector.php lists the rules skipped on purpose, with the reasons.
Feature tests include:
- Token/auth flows
- Email verification and password reset workflows
- Security and unhappy-path scenarios
- Localization behaviour
- Idempotency, deprecation, pagination and conditional requests
- OpenAPI generation and contract verification: every
v1/route must be in the spec, and each runtime check’s status, body and media type must match it
api-toolbox’s assertions (assertProblem, assertIdempotentReplay, assertDeprecated,
assertLinkHeader and others) are available on every test response.
AI Agents
AGENTS.md is the guide to building on Kit: the checks that must pass, the recipe for
an endpoint, how errors, the HTTP standards, localization, security and dependencies work here, and
which files are generated. Coding agents that follow the AGENTS.md convention read it directly;
CLAUDE.md imports it for Claude Code.
Below that, CLAUDE.md carries the Laravel Boost guidelines, with
api-toolbox’s guideline and api-toolbox-development skill opted in through boost.json. Run
php artisan boost:update after upgrading packages; it rewrites only its tagged block. To add Boost
for another agent, run php artisan boost:install and pick it: agents that read AGENTS.md get
Boost’s block appended below Kit’s guide, which it leaves alone.
CI & Dependency Automation
GitHub Actions workflows:
.github/workflows/ci-tests.yml- Runs Pint, Rector, PHPStan, the test suite and legible on every push and pull request
- Builds the production image, boots it with production settings and makes requests to it
.github/workflows/dependency-updates.yml- Runs weekly, Mondays at
03:00 UTC - Executes
composer update, holding back releases younger than 7 days - Opens/updates PR titled
bot: dependency updateswith Vet’s report of what needs review - When only
laravel/*packages changed, it records their trust itself and the PR needs no review - Otherwise the PR cannot pass CI until
vet.jsonis updated on its branch: check it out, runcomposer install --no-plugins, then./vendor/bin/vet, and commitvet.json
- Runs weekly, Mondays at
.github/workflows/security-gate.yml- Runs Composer security audit
- Fails on high/critical advisories
- Runs repository secret scan with Gitleaks
Project Structure
AGENTS.md how to build on Kit, for agents and peopleDockerfile the production image (Octane on FrankenPHP), with docker/vet.json packages trusted by Laravel Vet.legible.yaml the agent-readiness gateapp/ Console/Commands/ make:endpoint Http/ Controllers/Api/V1/Auth/ Middleware/ Payloads/V1/ Requests/Auth/ Resources/ Notifications/ queued verification and reset emails Webhooks/ webhook events, delivery and signing Support/routes/ api/ routes.php v1.phptests/ Feature/stubs/endpoint/ templates for make:endpointconfig/ api-toolbox.php octane.php webhooks.php sanctum.php scribe.php security.phplang/ en/, es/ Kit's messages and validation vendor/api-toolbox/es/ Spanish for api-toolbox's problems.github/ workflows/License
MIT
Related
More Packages
Same subject
In other formats
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.
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
GuideBulk updates via async jobs
Move heavy bulk operations out of the request/response cycle with job resources. Enqueue work, return quickly, let clients poll for status.
API Design