Kit docs
Building endpoints
PHP ^8.513 pages
Docs for v0.2.1, from GitHub.
Pages in these docs
Every endpoint in Kit is built from the same six pieces: a route, a Form Request, a payload, an
invokable controller, a resource and a test. make:endpoint writes all of them, so that is where
every new endpoint starts.
The examples on this page add a projects domain. Kit has no Project model, so make one first
(php artisan make:model Project -mf) if you are following along.
Generate it
php artisan make:endpoint Projects/CreateProject --method=POST --uri=/projectsphp artisan make:endpoint Projects/ShowProject --method=GET --uri=/projects/{project}The name is {Domain}/{Action}. The options:
| Option | Default | Effect |
|---|---|---|
--method= | GET, POST, PUT, PATCH or DELETE | |
--uri= | The path under /v1 | |
--ability= | projects:read for a GET, projects:write otherwise | The Sanctum ability the route checks |
--guest | Leaves the route public, with no token or ability | |
--force | Overwrites files that already exist |
It writes the controller, the payload, a Pest test and, for anything but a DELETE, a Form Request.
Then it appends the route to routes/api/v1.php, runs Pint over the new files and prints what is
left for you to do. The templates live in stubs/endpoint/, and every endpoint you generate after
changing them follows suit.
Anything the generator cannot know stays visible. The controller answers 501, each path parameter
has a TODO asking for a constraint, and the description is three TODO sentences. Both the contract
tests and legible fail while that documentation is still there, so a half-written endpoint does not
get through CI.
Finish the endpoint before you run composer rector. To Rector, the generator’s placeholders (an
unused payload, an empty constructor) are dead code, and it deletes them.
The route
The generator appends something like this:
Route::post('/projects', CreateProjectController::class) ->middleware(['auth:sanctum', 'throttle:auth-protected', 'abilities:projects:write', 'idempotent']) ->name('v1.projects.store');routes/api/routes.phpadds thev1prefix, so the path here is/projectsand the URL is/v1/projects. There is no/apiprefix anywhere.- Names are
v1.{domain}.{action}. The generator maps verbs to Laravel’s resource names, soCreateProjectbecomesstoreandShowProjectbecomesshow. - A POST gets
idempotent, so clients can retry it safely with anIdempotency-Key. See HTTP standards. - New routes share the
auth-protectedlimiter. When a domain needs its own limit, add a named limiter inAppServiceProvider::boot()and use that instead.
Then do the two things the generator prints:
-
Add the ability to
abilities.availableinconfig/sanctum.php, and toabilities.defaultif every signed-in client should hold it. Until you do, nobody can call the route. See Authentication. -
Constrain path parameters on the route itself, never in the Form Request:
Route::get('/projects/{project}', ShowProjectController::class)->whereUlid('project')->middleware(['auth:sanctum', 'throttle:auth-protected', 'abilities:projects:read'])->name('v1.projects.show');A Form Request that validates a path parameter also documents it as a body field, and legible reports the collision.
The Form Request
app/Http/Requests/Projects/CreateProjectRequest.php validates body or query input, and nothing
else:
public function rules(): array{ return [ 'name' => ['required', 'string', 'max:255'], 'description' => ['nullable', 'string', 'max:2000'], ];}A GET’s Form Request also has a queryParameters() method describing each query parameter. Without
it, Scribe documents a GET’s rules as a request body. ShowResetPasswordTokenRequest is an example.
The payload
app/Http/Payloads/V1/CreateProjectPayload.php turns validated input into typed properties, so the
controller never touches $request->input():
final readonly class CreateProjectPayload{ public function __construct( public string $name, public ?string $description, ) {}
public static function fromRequest(Request $request): self { $data = self::validatedData($request);
return new self( name: Arr::string($data, 'name'), description: is_string($data['description'] ?? null) ? $data['description'] : null, ); }
// validatedData() as generated}PHPStan runs at level 10 and will not let you cast mixed, so read each value with a typed
accessor such as Arr::string() or Arr::integer(). Path parameters come from
App\Http\Payloads\RouteParameter:
$projectId = RouteParameter::string($request, 'project');The controller
app/Http/Controllers/Api/V1/Projects/CreateProjectController.php is final, invokable and extends
nothing. Replace the 501 with the work and the response:
public function __invoke(CreateProjectRequest $request, #[CurrentUser] User $user): JsonResponse{ $payload = CreateProjectPayload::fromRequest($request);
$project = $user->projects()->create([ 'name' => $payload->name, 'description' => $payload->description, ]);
return ProjectResource::make($project)->response()->setStatusCode(201);}The Scribe attributes on the class are the endpoint’s documentation, and CI checks them. The
#[Endpoint] description wants three sentences: what it does, when a client should call it, and
what it returns. Then add a #[BodyParam] or #[QueryParam] for each input, and a
#[ResponseFromApiResource] for the success response once the resource exists.
Document the standard headers the route reads with #[Header]: Idempotency-Key on an
idempotent POST, which the generator adds for you, If-Match on a write that requires it, and
If-None-Match on a GET behind conditional. Their descriptions come from
HeaderDescriptionsOpenApiGenerator, so the attribute only needs the name and an example.
Each error status the endpoint can return needs a #[Response] written as a problem. The generator
has already written the 401 and 403, plus a 422 with a TODO example when there is a Form
Request. LoginController is a finished example of the whole set.
Errors are thrown, never returned as JSON. See Errors.
The resource
Kit’s resources are plain JsonResource classes, wrapped in data. Make one in
app/Http/Resources/ and list every field:
final class ProjectResource extends JsonResource{ public function toArray(Request $request): array { /** @var Project $project */ $project = $this->resource;
return [ 'id' => (string) $project->id, 'name' => $project->name, 'description' => $project->description, 'created_at' => $project->created_at?->toAtomString(), ]; }}IDs are strings and dates are ISO 8601. Because every field is listed by hand, a column you add to the table later never ends up in the API by accident.
A list endpoint returns a CursorPage. Laravel’s paginated resources put navigation in the body,
and Kit puts it in Link headers:
return CursorPage::of($user->projects()->latest('id')->cursorPaginate(25), ProjectResource::class);HTTP standards covers what clients get from it.
The test
The generated test has the authentication and ability cases passing straight away, and a todo()
for the success path. Write that, then cover validation and anything the endpoint does with the
standards, such as an idempotent replay:
it('creates a project', function (): void { $token = User::factory()->create()->createToken('test', ['projects:write'])->plainTextToken;
$this->withToken($token) ->postJson('/v1/projects', ['name' => 'Kit']) ->assertCreated() ->assertJsonPath('data.name', 'Kit');});
it('refuses a project without a name', function (): void { $token = User::factory()->create()->createToken('test', ['projects:write'])->plainTextToken;
$this->withToken($token) ->postJson('/v1/projects', []) ->assertProblem(422);});api-toolbox adds assertions such as assertProblem(), assertProblemType(),
assertIdempotentReplay() and assertLinkHeader() to every test response.
Finish
-
Add a runtime check for the endpoint to
OpenApiContractTest, so its real responses are validated against the spec. -
If the endpoint has a Form Request, add it to the
error-shapeignore in.legible.yaml. Validation problems carry anerrorsmember the other problems do not, and the reason in that file explains why that is fine. -
Regenerate the docs and run the checks:
Terminal window php artisan scribe:generate --no-interactioncomposer stanphp artisan test -
Commit
public/docs/and.scribe/with the endpoint.
API documentation explains what the contract tests and legible check.
Taking a type other than JSON
consumes:application/json sits on the whole v1 group, so every write route accepts JSON and
nothing else unless it says otherwise. A route that declares its own consumes: replaces the
group’s:
Route::post('/imports', CreateImportController::class) ->middleware('consumes:multipart/form-data');List every type the route takes, JSON included if it still takes JSON:
consumes:application/json,multipart/form-data.
A merge patch PATCH is the other common case. HTTP standards
shows the route.