How a request moves through a Dotkernel application
Every Dotkernel application is a stack of PSR-15 middleware with no framework runtime hidden underneath it. A request enters at the error boundary, passes through routing, negotiation, authentication and authorization, and reaches your handler - and you can read every layer it crossed on the way.
Framework-less, not framework-free
Dotkernel is an opinionated, framework-less tool aimed at intermediate-to-advanced programmers. Framework-less does not mean nothing is underneath: Mezzio and Laminas components are there, and they are excellent. It means there is no framework runtime deciding things on your behalf. The pipeline is a list you wrote, in a file you can open, executed top to bottom.
That is the whole trade. You give up the convenience of conventions you never have to look at, and you get an application where every layer between the socket and your handler is named, ordered and yours to reorder.
Gradual learning curve
Fine-tuned security
KISS, YAGNI, DRY
Long-term support
Nineteen stages, in the order they run
This is the default pipeline of Dotkernel API, unedited. Each stage receives the request, may hand it onward, and may alter the response on the way back out - which is why the error boundary is first in and last out.
The outermost layer, deliberately. Anything thrown deeper in the stack is handed to
dot-errorhandler for logging and comes back as a problem details response, so a
failure reaches the client as a documented error shape rather than a stack trace.
Rejects a body that is not valid JSON up front, before any later stage tries to read it and fails in a less legible place.
Parses the raw request body into the parsed body according to its Content-Type.
Seeds the server URL helper with the current scheme and host, so anything generating a URL later produces an absolute one.
Recognises preflight requests and answers them, and attaches the configured CORS headers to everything else. It sits before routing so a preflight never needs a matched route.
Matches the request against the route table and registers the RouteResult
attribute. Everything below this line can ask which route was matched - and most of it does.
Answers HEAD for routes that only declare GET.
Answers OPTIONS with the methods the matched path actually allows.
Returns 405 Method Not Allowed when the path matched but the method did not. Order
matters here: it has to sit after both implicit handlers, or it would answer the requests they
exist to serve.
Reconciles the client's Accept and Content-Type with what the matched
route is configured to serve, so diverse consumers share one API without custom glue.
The mechanism behind API evolution. It reads the #[ResourceDeprecation] attribute
off the handler and, when one is present, sets the sunset and link
response headers - the client learns an endpoint is going away from the endpoint itself,
rather than from a migration guide.
Applies the headers you configure application-wide to every outgoing response, in one place instead of per handler.
Seeds the URL helper with the routing result, so handlers and HAL links can be generated relative to the route that was actually matched.
Validates the OAuth 2.0 Bearer token. Routes declared as authenticated answer
401 Unauthorized without a valid one, and never reach the handler.
Checks the authenticated identity's role against the permissions allocated to the matched route
name, and returns 403 Forbidden when it falls short. The decision lives in
configuration, not scattered through handlers.
Reads the #[Resource] attribute on the handler's handle() method and
loads the named entity through Doctrine using the route placeholder. The handler receives a
resource rather than an identifier, and a record that does not exist becomes a
404 before your code runs.
Hands the request to the matched PSR-15 handler. This is your code - everything above was getting the request into a state where your handler only has to do its own job.
Reached only when nothing above returned a response: emits 404 in problem details
form.
The last stage in the pipeline, shaping the not-found response for API clients.
The packages underneath, in the same order
The same journey seen one level down - which library owns each step. The second column shows how little changes when a feature is added: sending an email inserts one package and leaves the rest of the flow alone.
Default flow
A request that reads or writes data and returns a resource.
Flow for sending an email
Identical until the domain layer, where dot-mail joins in.
Built on interfaces from the Framework Interop Group
Dotkernel applications are collections of PSR-7 middleware. The standards below are not badges - they are the reason a package from one vendor drops into a pipeline built by another.
PSR-7
HTTP messages are immutable value objects. Every stage above receives a request and returns a response, and neither is a global you have to guard.
PSR-15
Middleware and request handlers share one interface, which is what makes the pipeline a plain ordered list rather than a framework-specific event graph.
PSR-11
The application is container-based, with each module declaring its dependencies in a
ConfigProvider.
PSR-4
Classes are located by autoloader from their namespace, so where a file lives is never a question of configuration.
PSR-3
Errors are logged through LoggerInterface via dot-errorhandler,
reached from the error boundary at stage one.
Why it matters
You can delete any stage in the pipeline, or write your own and pipe it in, without asking a framework's permission. That is the practical payoff of standardising on interfaces.
One pipeline shape, three applications
The stages differ where the job differs - Admin adds sessions and CSRF-protected forms, Queue
swaps HTTP dispatch for a message consumer - but the pattern is constant, and all three declare
the same Core namespaces underneath.
Core\App Core\Admin Core\User
Core\Security Core\Setting
Kept as its own Git repository and added as a submodule, so an entity means the same thing to the endpoint that creates it, the admin screen that moderates it and the worker that emails it.
The pipeline is one file
Everything on this page comes from config/pipeline.php - about ninety lines, most of
them comments explaining where to add your own middleware. There is no generated layer between
that file and what runs.
php ./bin/cli.php route:list
Once the pipeline has routed a request, the route table is the other half of the picture. This command lists every endpoint the application exposes, which is the fastest way to review an API you have just inherited.
Database migrations are generated and run the same way, through the Doctrine console commands
registered by dot-cli.
No layer you are not allowed to read.
Dotkernel is developed and led by the dev team at Apidemia - built first as an internal tool for handling complex architectures, released under MIT as our way of giving back to the community.