The Five Controllers
All five are generic over the resource type:
@template TResource of AbstractResource. Declare the concrete type via the @extends annotation so static analysis and IDE autocompletion work.
The HTTP column is enforced, not a convention. Each controller derives its request and response documentation from the method it is built for, so a mismatch would publish a contract the controller does not implement. A buildRoute() returning the wrong method throws an InvalidArgumentException while the route cache is built, meaning at boot rather than at request time:
POST and PUT because it keeps the resource’s required properties required, which is correct for a create and for a full replace. See Partial vs full update.
What You Write
Every resource controller implements three things:getResourceClass(): string— return your resource class name.- A route declaration method — see the table below.
__invoke(ResourceRequest $request): AbstractApivalkResponse— your business logic.
getRequestClass(), getResponseClasses(), and getEmptyResource(). Create and Update also expose a getResource() helper — see below.
Route declaration: buildRoute()
All five resource controllers use buildRoute() — never implement getRoute() directly. The base class provides a getRoute() that calls your buildRoute() and auto-injects extras:
You only declare the URL, path parameters, authorization, pagination, and rate limit in
buildRoute().
Examples
Create
View
Update
Delete
List
ImplementbuildRoute() instead of getRoute(). Tags, filters, and sortings from the resource are injected automatically — declare only the URL, authorization, pagination, and rate limit.
Partial vs Full Update (PATCH vs PUT)
AbstractUpdateResourceController documents every body property as optional, so a client may send only the fields it wants to change. That is PATCH semantics (RFC 5789), and it is only correct for PATCH. A PUT route on the same controller would advertise a full replace while accepting a partial body.
To keep the two from drifting apart, getRoute() rejects anything but PATCH:
pathProperty() always enforces isRequired(true), because the route only matches when the segment is present.
Building a PUT endpoint
There is no dedicated PUT resource controller, because full replace and upsert are per-API decisions rather than framework defaults. Build one onAbstractCreateResourceController: it resolves to MODE_CREATE, which leaves the resource’s required properties required — exactly the full-replace semantics PUT needs, and its method guard accepts PUT for that reason.
Two things need handling that Create does not do for you:
- Exclude the identifier from the body. The identifier arrives in the path, so
excludeFromMode()must drop it forMODE_CREATE— otherwise it is documented as required in both the body and the path, and clients have to send it twice. - Copy path parameters onto the resource yourself.
AbstractCreateResourceController::getResource()deliberately ignores path parameters (on a plain create the identifier does not exist yet).AbstractUpdateResourceControllerdoes copy them; Create does not.
Weigh whether you need PUT at all. Full replace is rarely what a client wants on a resource with server-generated fields, and the upsert half requires the client to pick the identifier. PATCH via
AbstractUpdateResourceController covers most update endpoints.Nested Resources
BecausebuildRoute() is fully explicit, nested URLs are straightforward — add more path segments and declare each path parameter:
->pathProperty() call documents the parameter in OpenAPI and registers it for validation.
getResource() Helper
AbstractCreateResourceController and AbstractUpdateResourceController expose a typed $this->getResource($request) method that returns a fully populated TResource.
Create — body only. Path parameters are intentionally excluded because the identifier doesn’t exist yet (the server generates it after persisting).
Update — body fields first, then any path parameter whose name matches a resource property is automatically set. If your route has {animal_uuid} and AnimalResource declares an animal_uuid property, $resource->animal_uuid is already populated after calling getResource(). For nested resources with multiple path params (e.g. {user_uuid}/{authenticator_uuid}), only the ones whose names appear as resource properties are set — parent-scope params are ignored.
If you need the raw path value directly without the helper, $request->path()->key always works.
What the Base Class Provides
getRequestClass()— returnsResourceRequest::classby default (a shared, empty request class).getResponseClasses()— the mode-appropriate success response +BadRequestApivalkResponse+ForbiddenApivalkResponse.getEmptyResource()— instantiates the resource class returned bygetResourceClass(), useful insidebuildRoute().
IDE Autocomplete via the DocBlock Generator
After running the docblock generator, a typed request class is generated for each controller that has path parameters, sorting, filtering, or pagination (e.g.AnimalViewRequest, AnimalUpdateRequest, AnimalListRequest). To get full IDE autocomplete in __invoke, add a @param annotation with the generated class:
Update, the same annotation gives you both typed $request->path() and typed $this->getResource($request):
List, annotate with AnimalListRequest to get typed sorting(), filtering(), and paginator().