Creating a Controller
Every controller must extend AbstractApivalkController and implement two methods:
getRoute(): Defines the path and HTTP method.
__invoke(): The main execution logic.
__invoke() carries the documentation as well. The request class is read from its parameter, the response classes from the new expressions its body returns, so neither is declared a second time.
Example
Typing $request
Name your own request class in __invoke():
This works because AbstractApivalkController does not declare __invoke() as an abstract method. If it did, every controller would be pinned to that signature: PHP allows an override to widen a parameter type, never to narrow it, so __invoke(GetPetRequest $request) would be a fatal error in every PHP version. With no inherited signature there is nothing to be compatible with.
The contract is kept as a @method annotation on the base class, so static analysis still knows controllers are callable.
What is lost is the compile-time guarantee that __invoke() exists at all. Two runtime checks take its place: RouteCacheFactory rejects such a controller while indexing routes, and ApivalkControllerFactory rejects it when instantiating one, both with a LogicException naming the class. The first catches it when the route cache is built, the second on every request, so a controller that reaches dispatch without __invoke() fails with that message rather than “object is not callable” from inside the middleware stack.
Be aware that your IDE will not flag the missing method while you write it, the way it did when the base class declared it abstract. That is the price for naming your own request class.
ApivalkRequestInterface still works as the parameter type if you prefer it, for instance in a controller that handles several request shapes. An abstract request class, or no type hint at all, behaves the same way: none of them names something to instantiate, so the request class falls back to ResourceRequest, which carries what the route declares and nothing else.
Three signatures are rejected instead, each with a LogicException naming the controller:
__invoke() with no parameter.
- A builtin parameter type such as
string.
- A class that does not implement
ApivalkRequestInterface.
Documented Responses
ControllerResponseScanner reads the response classes out of the __invoke() body while the OpenAPI document is generated, never per request.
The scan is syntactic. It reads the source of __invoke() and looks for new expressions. It does not follow method calls, does not resolve return types, and does not run anything.
Every response an endpoint documents has to be constructed with a literal new inside __invoke() itself.A response built anywhere else, in a private helper, in a service, in a factory, is invisible to the scanner. The endpoint keeps answering with it at runtime, but the generated document no longer mentions it, and nothing warns you.
What the scanner finds
Every branch of a return expression is read, so a ternary or a null coalesce documents both of its outcomes:
A response that is constructed but never returned stays out of the spec. Aliased imports, group imports, fully qualified names and classes from the controller’s own namespace all resolve.
What it misses
Each of these is valid PHP, answers the client correctly, and documents nothing:
The first two are the ones that bite in practice, and they usually hit the success response, because that is the one worth building somewhere reusable. The endpoint is then published with no 2xx at all, carrying only the error responses derived from its route.
Move the construction into __invoke(). Where a collaborator assembles the data, let it return the data and build the response at the exit:
Only one thing throws: an __invoke() the scan finds no response class in at all. That is a LogicException naming the controller, rather than publishing a spec that is quietly wrong. An anonymous class has no name to put in a document and is skipped, so returning one is caught by the same rule, but only when it was the sole response.
Note how little that catches. A controller that builds every response through a helper fails loudly. One that builds some of them that way, typically the success response with the error branches returned inline, scans clean and ships an incomplete document.
ControllerResponseScanner::scan() returns exactly what the generator will document, so a controller test can pin it:Worth doing for every endpoint whose success response is not a plain return new ….
The error responses the framework produces around your controller are added on top of these, derived from the route. See OpenAPI Generator.
Dependency Injection
If you configured a PSR-11 container, you can use constructor injection:
Route Discovery
Apivalk uses ClassLocator to scan your controller directory. You don’t need to register routes manually; the framework discovers them automatically by calling getRoute() on any class extending AbstractApivalkController.
For endpoints returning lists of data, see the Pagination guide.
Resource Controllers (CRUD)
If your endpoint follows a standard RESTful CRUD pattern against a single entity, you don’t need to author a controller class like the one above by hand. Apivalk ships five abstract base controllers — AbstractCreateResourceController, AbstractViewResourceController, AbstractUpdateResourceController, AbstractDeleteResourceController, AbstractListResourceController — that derive their route and OpenAPI documentation from an AbstractResource declaration.
A minimal list controller looks like this:
See the full Resources section for how to declare a resource and wire up all five CRUD endpoints.