Writing custom middleware
Quiote’s request pipeline is a plain PSR-15 stack (see The middleware pipeline). Adding your own behaviour — a health check, a JWT authenticator, a tenant resolver — means writing a standard PSR-15 middleware and telling the pipeline where it goes.
There are three ways to place it, and they can be mixed freely:
- A
middleware.xml/.php/.yamlconfig file — declarative, no code: drop a file inConfig/and it’s registered, positioned bybefore/after/phase. Good for most apps, and the only way a module can register middleware just by being present (no app wiring). See Declarative middleware.xml below. MiddlewareCatalog::register()— imperative: you pass a factory and explicitbefore/after/priority. Good for one-off wiring in a bootstrap file.- The
#[Middleware]attribute +MiddlewareCatalog::registerAttributed()— declarative in code: the class states its own placement, and it is ordered in the same pass as the framework’s own middleware (which is how they order themselves). Good for reusable middleware that ships with its position.
All three are shown below.
How your middleware fits in a request
Section titled “How your middleware fits in a request”Quiote builds the pipeline once — lazily, on the first request, then cached for the worker’s lifetime. Your middleware becomes one link in that chain.
- How the framework finds and orders it. Every framework middleware carries a
#[Middleware]attribute; a scanner reads them and a topological resolver sorts them byphase, thenbefore/afterconstraints, thenpriority. Your middleware joins that same ordering pass when you use the#[Middleware]attribute +registerAttributed(), or via a declarativemiddleware.*config entry.register()is different: it is spliced into the already-built stack at the position you name (defaulting to just afterValidationMiddleware). - The path a request takes. Each middleware wraps the next, so a request travels inward and the response travels back out:
Kernel boots and builds the pipeline → request enters
ErrorHandlingMiddleware(outermost) →SessionMiddleware→ … →RoutingMiddlewareresolves the action →SecurityMiddlewarechecks access →ValidationMiddlewarevalidates input → your middleware (default position) →DispatchMiddlewareruns the action and renders the view → the response unwinds back out through every layer → it is emitted.
Where you place your middleware decides both what it can see on the way in (a route-aware check needs to run after RoutingMiddleware) and what it can touch on the way out (see the ErrorHandlingMiddleware caution below). The full ordering is in Built-in anchor points; for the complete lifecycle see The request lifecycle and The middleware pipeline.
Write a PSR-15 middleware
Section titled “Write a PSR-15 middleware”Your middleware implements Psr\Http\Server\MiddlewareInterface — nothing Quiote-specific:
<?phpnamespace App\Middleware;
use Psr\Http\Message\ResponseInterface;use Psr\Http\Message\ServerRequestInterface;use Psr\Http\Server\MiddlewareInterface;use Psr\Http\Server\RequestHandlerInterface;use Nyholm\Psr7\Response;
final class HealthzMiddleware implements MiddlewareInterface{ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { if ($request->getUri()->getPath() === '/healthz') { return new Response(200, ['Content-Type' => 'text/plain'], 'ok'); } return $handler->handle($request); // pass through }}Two things every middleware does: it can short-circuit by returning a response (as /healthz does), or delegate downstream with $handler->handle($request) and optionally post-process the returned response on the way back out.
One instance per worker, not per request
Section titled “One instance per worker, not per request”The pipeline is built once per worker process and the same middleware objects then serve every request that worker handles — thousands of them, from as many different users. This is the one place where PSR-15 intuition (new middleware per request, as a fresh-per-request framework would do it) will get you into trouble, and it does so silently:
final class TenantMiddleware implements MiddlewareInterface{ private ?string $tenantId = null; // ← survives the request
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $this->tenantId ??= $this->resolve($request); // ← request 2 reuses request 1's tenant return $handler->handle($request->withAttribute('tenant', $this->tenantId)); }}Under php-fpm that code is correct, because the process ends with the request. Under FrankenPHP, RoadRunner or Swoole it hands the second caller the first caller’s tenant, and a memo of anything user-specific — an identity, a permission set, a resolved account — becomes a cross-user data leak rather than a stale-cache bug.
The rule: instance properties are for values that are genuinely process-wide — configuration, a shared connection, a compiled lookup table, collaborators injected at construction. Request-scoped values belong on the request itself ($request->withAttribute(), read back with getAttribute()) or are resolved fresh per call from the container:
final class TenantMiddleware implements MiddlewareInterface{ public function __construct(private readonly TenantResolver $resolver) {} // fine: process-wide
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { return $handler->handle($request->withAttribute('tenant', $this->resolver->resolve($request))); }}If a middleware genuinely must hold state across its own process() — a buffer it fills on the way in and drains on the way out — implement Symfony\Contracts\Service\ResetInterface and clear it there:
use Symfony\Contracts\Service\ResetInterface;
final class CollectingMiddleware implements MiddlewareInterface, ResetInterface{ /** @var list<string> */ private array $collected = [];
public function reset(): void { $this->collected = []; }}MiddlewarePipeline::resetInstances() calls reset() on every middleware in the built stack that implements it, and the context runs that at the end of each request, alongside dropping the session bag and the user. The stack itself is kept — this is the request boundary, not a rebuild — and a reset() that throws is logged without stopping the others.
Declarative middleware.xml
Section titled “Declarative middleware.xml”The no-code way. Drop a middleware.xml (or .php/.yaml/.yml) next to settings.xml in Config/, or inside any module’s own Config/ directory — resolved the same way as any other config type (.php > .yaml/.yml > .xml). This is a drop-in: a module registers its own middleware just by containing the file, no app wiring required.
return [ ['class' => \App\Middleware\HealthzMiddleware::class, 'phase' => 'pre_routing', 'before' => 'SessionMiddleware'],];- class: App\Middleware\HealthzMiddleware phase: pre_routing before: SessionMiddleware<?xml version="1.0" encoding="UTF-8"?><ae:configurations xmlns:ae="http://quiote.dev/quiote/config/global/envelope/1.1" xmlns="http://quiote.dev/quiote/config/parts/middleware/1.1"> <ae:configuration> <use class="App\Middleware\HealthzMiddleware" phase="pre_routing" before="SessionMiddleware" /> </ae:configuration></ae:configurations>The PHP array shape above is exactly the canonical shape any format compiles to. Each entry is resolved via the DI container — like registerAttributed() below, no factory closures in config — and merged with any #[Middleware] attribute the class already carries: a field left unset (null) keeps the attribute’s own value or the framework default; a field that’s set overrides it. A class with no attribute at all can be declared purely through config — phase defaults to 'pre' if omitted, same as the #[Middleware] attribute’s own constructor default.
Framework middleware is protected by default. Naming one of Quiote’s own shipped classes (ErrorHandlingMiddleware, SessionMiddleware, RoutingMiddleware, SecurityMiddleware, etc. — see MiddlewarePipeline::coreMiddlewareClasses()) to change its enabled state or placement requires both: override_framework: true on that specific entry (override-framework="true" in XML — the attribute is hyphenated, the PHP/YAML key underscored), and the global core.middleware.allow_framework_overrides setting set to true. Either alone is refused with a ConfigurationException at config-load time (not silently ignored, and not deferred to the first request) — a config file, least of all one dropped in by a third-party module, shouldn’t be able to silently disable error handling or CSRF just by declaring an entry.
Option 1 — register()
Section titled “Option 1 — register()”The imperative way. Application middleware is added through Quiote\Middleware\MiddlewareCatalog::register():
public static function register( string $fqcn, // identity + label in the debug stack (use the class name) callable $factory, // () => PSR-15 MiddlewareInterface (lazy; called when the pipeline builds) ?string $after = null, // insert immediately AFTER this middleware's FQCN ?string $before = null, // insert immediately BEFORE this middleware's FQCN int $priority = 0, // tie-break ordering among registered middleware): void$factoryis called once, when the pipeline is first built. It returns the middleware instance — keep construction lazy so it does not run at registration time.$after/$beforeposition your middleware relative to a built-in (see the anchor list below) or another registered middleware.- If you give neither, the middleware is inserted just after
ValidationMiddleware— a safe default: the route is resolved, access is checked, and input is validated, but the action has not run yet.
use App\Middleware\HealthzMiddleware;use App\Middleware\JwtAuthMiddleware;use Quiote\Middleware\MiddlewareCatalog;use Quiote\Middleware\RoutingMiddleware;use Quiote\Middleware\SessionMiddleware;
MiddlewareCatalog::register( HealthzMiddleware::class, fn() => new HealthzMiddleware(), before: SessionMiddleware::class, // answer /healthz before touching the session);
MiddlewareCatalog::register( JwtAuthMiddleware::class, fn() => new JwtAuthMiddleware(), after: RoutingMiddleware::class, // needs the matched route);Register before the kernel runs
Section titled “Register before the kernel runs”The pipeline is built lazily on the first request and cached for the worker’s lifetime. All registrations must happen before Kernel::run(). Do it in a bootstrap class called from your front controller:
final class MiddlewareBootstrap{ public static function register(): void { MiddlewareCatalog::register(/* ... */); }}// pub/index.php — before the kernel runsApp\Bootstrap\MiddlewareBootstrap::register();
Quiote\Runtime\Kernel::create([ 'app_dir' => dirname(__DIR__), 'env' => getenv('QUIOTE_ENV') ?: 'production', 'context' => 'web',])->run();Built-in anchor points
Section titled “Built-in anchor points”Use any of these FQCNs (in Quiote\Middleware\) as a before: / after: target. This is the order their #[Middleware] attributes resolve to — outermost first:
ErrorHandlingMiddleware (outermost — catches everything)TelemetryMiddlewareSessionMiddlewareTimingMiddlewareTraceMiddlewarePayloadParsingMiddlewareContentNegotiationMiddlewareRoutingMiddleware (route is known after this)OutputTypeSyncMiddlewareCsrfInjectionMiddlewareCsrfValidationMiddlewareSecurityMiddleware (authentication / authorization)ValidationMiddlewareSlotMiddlewareDispatchMiddleware (runs the action — effectively terminal)AssetAggregationMiddlewareFormPopulationMiddlewareExecutionTimeMiddlewarePlace your middleware relative to the earliest built-in whose work it depends on. A route-aware middleware goes after: RoutingMiddleware; something that must run before the session is touched goes before: SessionMiddleware.
ErrorHandlingMiddleware before and after are not symmetric
Section titled “ErrorHandlingMiddleware before and after are not symmetric”ErrorHandlingMiddleware is the one anchor where before:/after: don’t just mean “earlier/later” the way they do everywhere else — because it wraps the rest of the stack in a try/catch, which side you’re on determines whether your middleware runs at all on an error response:
after: ErrorHandlingMiddlewareplaces your middleware inside the try/catch — closer to the handler. If something downstream (the action, a renderer) throws, the exception unwinds straight past your middleware on its way to being caught. Anything your middleware does on the way out — setting a response header, say — never happens on an error response.before: ErrorHandlingMiddlewareplaces your middleware outside it, wrapping it. It still runs on the way out even when an inner layer threw andErrorHandlingMiddlewareconverted that into an error response.
Ordering rules and caveats
Section titled “Ordering rules and caveats”- Register order matters for chains. If middleware B is positioned
after: Aand A is itself a registered middleware, register A first — otherwise A is not in the stack yet when B looks for it, and B falls back to “afterValidationMiddleware”. Usepriorityto make intent explicit rather than relying on registration order. - Register once. Registrations are process-global static state, keyed by FQCN. Registering the same class twice overwrites the earlier entry.
- A missing target falls back. If the named
before:/after:target is not found, the safe default (afterValidationMiddleware) applies. - Enable/disable. Any middleware — framework or attributed — can be toggled off with a
<use class="…" enabled="false" />entry (orenabled: falsein PHP/YAML) in amiddleware.{xml,php,yaml}config file (see The middleware pipeline); that entry overrides the attribute’senableddefault. (register()-ed middleware is skipped when its FQCN is disabled the same way.)
Verify the position
Section titled “Verify the position”Do not reason about ordering — assert it. MiddlewarePipeline::debugStack() returns the ordered list of labels for the pipeline as actually built, including your middleware:
$stack = $pipeline->debugStack();// assert HealthzMiddleware appears before SessionMiddleware, etc.Option 2 — the #[Middleware] attribute
Section titled “Option 2 — the #[Middleware] attribute”The declarative way. Every framework middleware carries a #[Quiote\Middleware\Attribute\Middleware] attribute that states its own placement, and the pipeline computes the order from these attributes — a scanner reads them and a topological resolver sorts them. This is not decorative: it is the actual source of the pipeline order. Your middleware can join that same ordering pass.
Put the attribute on your class and opt it into scanning with registerAttributed():
<?phpnamespace App\Middleware;
use Psr\Http\Server\MiddlewareInterface;use Psr\Http\Server\RequestHandlerInterface;use Psr\Http\Message\ResponseInterface;use Psr\Http\Message\ServerRequestInterface;use Quiote\Middleware\Attribute\Middleware;
#[Middleware(phase: 'before_action', after: 'RoutingMiddleware')]final class JwtAuthMiddleware implements MiddlewareInterface{ public function __construct(private TokenVerifier $tokens) {} // autowired
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { // ... return $handler->handle($request); }}// bootstrap, before Kernel::run()use Quiote\Middleware\MiddlewareCatalog;
MiddlewareCatalog::registerAttributed(App\Middleware\JwtAuthMiddleware::class);Unlike register(), there is no factory: an attributed middleware is built through the DI container, so its constructor dependencies are autowired.
The attribute
Section titled “The attribute”#[Middleware( phase: 'before_action', // which band it runs in (see below) priority: 0, // higher runs earlier within the band before: null, // run before this middleware (short name or FQCN) after: null, // run after this middleware (short name or FQCN) enabled: true, // default on; overridable via a middleware.* <use enabled> entry)]phaseis the primary sort key — one of, in order:bootstrap,pre_routing,pre,routing,before_action,action,after_action,finalize. It groups middleware into the same coarse bands the pipeline has always used.before/afterare hard ordering constraints (a topological sort). They may name a short class name ('RoutingMiddleware') or a fully-qualified name. A cycle is a build error; an unresolved or ambiguous name logs a diagnostic and the constraint is dropped.priority(higher first) plus scan order break remaining ties within a band.enabledis the default on/off state; amiddleware.*<use class="…" enabled="false" />entry overrides it (see The middleware pipeline).
register() vs registerAttributed()
Section titled “register() vs registerAttributed()”registerAttributed()joins the unified attribute ordering — it is sorted together with the framework middleware by phase/before/after/priority, and DI-resolved.register()is spliced in after that ordered stack is built, at the position itsbefore/after/priorityname (falling back to afterValidationMiddleware).- If the same class is passed to both,
register()wins outright — the attributed candidate is ignored and a warning is logged.
Either way, confirm placement with debugStack() (above) rather than reasoning about it.
Replacing the entire stack (the footgun)
Section titled “Replacing the entire stack (the footgun)”register() covers “add my middleware at this point” — the overwhelming majority of customization — and leaves every framework default intact around it. For the rare case where an application genuinely cannot run inside Quiote’s request lifecycle at all, MiddlewareCatalog::replaceCoreStack() discards the built-in stack completely and lets you supply your own.
use Quiote\Context;use Quiote\Middleware\MiddlewareCatalog;use Psr\Http\Server\MiddlewareInterface;
MiddlewareCatalog::replaceCoreStack( function (Context $context): array { return [ new MyErrorMiddleware(), new MyRouterMiddleware($context), new MyDispatchMiddleware($context), // ...the complete, ordered stack you want to run ]; }, MiddlewareCatalog::REPLACE_CORE_STACK_ACKNOWLEDGEMENT,);Two guardrails make this hard to trigger by accident:
- An exact acknowledgement string. The second argument must equal
MiddlewareCatalog::REPLACE_CORE_STACK_ACKNOWLEDGEMENTverbatim — a long, explicit constant (I_UNDERSTAND_THIS_DISCARDS_ERROR_HANDLING_SESSIONS_CSRF_SECURITY_AND_ROUTING). Anything else throwsInvalidArgumentException. A stray boolean or a config typo cannot flip this on. - A warning on every build. Whenever the replacement stack is built, the pipeline logs a
warningnaming what was bypassed, so the resulting behaviour is traceable in your logs rather than silent.
What you get and what you owe:
- Your factory receives the
Contextand returns the complete ordered list of PSR-15 middleware. There are no defaults around it. - Quiote still appends its terminal sentinel after your stack — that is a PSR-15 contract requirement (the pipeline must yield a response instead of returning null), not an opinion about your stack’s contents. So your stack must produce a response before reaching the end.
register()-ed middleware is not spliced in when a replacement is active. If you want any of it, add it inside your factory yourself.- Registered as it is (process-global static state),
replaceCoreStack()must be called at bootstrap, beforeKernel::run()— same timing asregister().MiddlewareCatalog::reset()clears it (along with registered middleware).
If you only need to remove a default or two — not the whole stack — do not use this. Disable the specific middlewares via MiddlewareCatalog::initialize([Fqcn::class => false]) (see The middleware pipeline) and keep everything else.
For copy-pasteable steps, see the Plugins & middleware quickstart: Write your own middleware.