HTTP API With Zero Per-Operation Controllers
Turns every operation of every worker registered in a Backbone package registry into an HTTP endpoint automatically — no per-operation controller code, self-documenting as HATEOAS resources and an OpenAPI 3.1 spec.
Why
Instead of writing POST /invoices/build → InvoiceController::build() by hand for each operation, you write one generic route pointing at one controller, and the URL path itself tells the package which worker method to call. The actual resolution and invocation is delegated entirely to derafu/backbone-dispatcher, which is transport-agnostic — this package only deals with the HTTP-specific concerns: parsing the route, listing packages/components/workers as browsable resources, serving OpenAPI documentation, and extracting parameters from the request body.
Installation
composer require derafu/backbone-api
There is no bundled route table or DI wiring — wiring Dispatcher and a controller into your framework’s routes and container is left to your application.
Routing Convention
Router::parse() resolves an incoming request into up to four path segments:
/[api/]:package/:component/:worker/:operation
The /api prefix is optional and stripped if present — /api/billing/document and /billing/document resolve identically. A path with more than 4 segments throws InvalidRouteException. Trailing segments are simply omitted rather than required, which is what drives the dispatch behavior below.
Two special one-segment paths are intercepted before being treated as a package name:
- Empty path or
index→ the root HATEOAS listing. openapi-docs.json→ the generated OpenAPI document.
public function dispatch(ServerRequestInterface $request): mixed
{
$route = $this->router->parse($request);
return match (true) {
$route->getId() === 'index' || $route->getId() === null => $this->handleRoot(),
$route->getId() === 'openapi-docs.json' => $this->documenter->document(),
$route->getComponent() === null => $this->handlePackage($route->getPackage()),
$route->getWorker() === null => $this->handleComponent($route->getPackage(), $route->getComponent()),
$route->getOperation() === null => $this->handleWorker(...),
default => $this->handleOperation($request, ...),
};
}
So GET /api/billing lists billing‘s components, GET /api/billing/document lists that component’s workers, and only a full 4-segment path actually invokes an operation.
Invoking an Operation
Every operation is invoked with the parameters read from the JSON request body’s parameters key — nothing else (no query string, no path params beyond routing):
POST /api/billing/invoice/builder/build
Content-Type: application/json
Accept: application/json
{ "parameters": { "number": "F-001", "amount": 15000 } }
$requestContent = json_decode($request->getBody()->getContents(), true);
$params = $requestContent['parameters'] ?? [];
return $this->dispatcher->dispatch($package, $component, $worker, $operation, $params);
Response Shape
A successful, JSON-wanting request (Accept: application/json or */*) gets wrapped in a small envelope:
{
"meta": { "timestamp": 1755500000.123456, "data_type": "integer" },
"data": 12
}
Two results bypass the envelope entirely: a PSR-7 ResponseInterface returned by the underlying dispatch (passed through unchanged), and the OpenAPI document itself (detected by its own openapi key). If the request doesn’t ask for JSON, the raw result is returned unwrapped for the framework layer to render however it sees fit.
Dispatcher is built against DirectDispatcherInterface — the tier of Backbone Dispatcher that does not catch anything. An operation that throws propagates the raw exception straight out of this package. There is no RFC 7807 mapping here (unlike the SafeDispatcher/ProblemDetail story at the backbone-dispatcher level), no HTTP status codes are ever set, and there is no authentication/authorization of any kind. All of that is deliberately left to the framework wrapping this controller — treat this package as the routing and dispatch-shape layer, not a complete, hardened API framework.
Autodiscovery
Two independent surfaces, each with its own criteria for what counts as “visible”:
Explorer walks the registry and lists every public method of a worker as a browsable HAL-style resource — GET /api/billing/document/builder returns the worker’s _links plus its operations, no filtering.
Documenter generates the OpenAPI 3.1 document served at GET /api/openapi-docs.json, but only includes methods explicitly marked with Backbone’s #[ApiResource] attribute:
#[ApiResource(
parametersExample: ['a' => 5, 'b' => 10],
responses: [200 => ['description' => 'Sum computed.']],
)]
public function sum(int $a, int $b = 10): int
{
return $a + $b;
}
A worker can have public methods that are browsable but never show up in the generated OpenAPI spec — that’s intentional curation, not an oversight. Every documented operation is generated as a single OpenAPI post entry (there’s no GET/PUT/DELETE distinction), and its request/response schema is built from the reflected parameter types, following the same type-name vocabulary as backbone-dispatcher’s Caster::resolveType() (string, number, integer, boolean, array, object).
Same terminology note as Backbone Dispatcher: an “operation” here is any public method found via reflection, unrelated to Backbone’s own JobInterface/#[Job] concept.
Requirements
PHP 8.5+. Depends on derafu/backbone, derafu/backbone-dispatcher, and psr/http-message.