Request Lifecycle: How Web Frameworks Handle a Request · PHP Frameworks: Laravel, Octane and Symfony
Symfony's HttpKernel and Kernel Events
Why a maintenance-mode listener redirected visitors to the login page instead of showing a maintenance notice, and how Symfony's kernel events and listener priorities decide what runs first.
Scenario
Example app: a Symfony application adds a MaintenanceListener on kernel.request that returns a 503 maintenance page when a flag is set. It's registered with the default priority.
What you see in production
- During maintenance, logged-out visitors to
/accountare redirected to the login page instead of seeing the maintenance notice. - Visitors to an unknown URL get a 404 instead of the maintenance page.
- Logged-in users see the maintenance page as intended.
The naive code
#[AsEventListener(event: KernelEvents::REQUEST)] // default priority 0
final class MaintenanceListener
{
public function __invoke(RequestEvent $event): void
{
if ($this->maintenance->isOn()) {
$event->setResponse(new Response($this->twig->render('maintenance.html.twig'), 503));
}
}
}
Reproduce it
kernel.request listeners run in priority order (higher first). Built-in listeners for routing and the security firewall run at positive priorities, so a priority-0 listener runs after them. When a listener sets a response, later listeners are skipped and the controller never runs.
<?php
function dispatchRequest(array $listeners, array $request): string {
usort($listeners, fn($a, $b) => $b["priority"] <=> $a["priority"]); // higher priority first
foreach ($listeners as $l) {
$response = $l["fn"]($request);
if ($response !== null) { // setResponse() stops propagation
return "{$l['name']} (priority {$l['priority']}) answered: $response";
}
}
return "controller runs";
}
$maintenanceOn = true;
$router = ["name" => "RouterListener", "priority" => 32,
"fn" => fn($r) => in_array($r["path"], ["/", "/account", "/login"]) ? null : "404 Not Found"];
$firewall = ["name" => "Firewall", "priority" => 8,
"fn" => fn($r) => ($r["path"] === "/account" && !$r["user"]) ? "302 redirect to /login" : null];
$maintenance = fn($priority) => ["name" => "MaintenanceListener", "priority" => $priority,
"fn" => fn($r) => $maintenanceOn ? "503 maintenance page" : null];
foreach ([0, 64] as $priority) {
echo "MaintenanceListener priority $priority:\n";
foreach ([["path" => "/account", "user" => null], ["path" => "/old-page", "user" => null]] as $request) {
echo " {$request['path']}: ", dispatchRequest([$router, $firewall, $maintenance($priority)], $request), "\n";
}
}
Output
MaintenanceListener priority 0: /account: Firewall (priority 8) answered: 302 redirect to /login /old-page: RouterListener (priority 32) answered: 404 Not Found MaintenanceListener priority 64: /account: MaintenanceListener (priority 64) answered: 503 maintenance page /old-page: MaintenanceListener (priority 64) answered: 503 maintenance page
Root cause
Symfony's HttpKernel handles a request by dispatching events, and most framework features are listeners on those events. On kernel.request, the router listener and the security firewall run before a default-priority listener, and either of them can end the request with a response (a 404, or a redirect to login) before maintenance mode is checked.
The lifecycle, step by step
public/index.phpboots the Kernel (container compiled and cached in production) and calls$kernel->handle($request).kernel.request: listeners run in priority order: session, routing (sets_controllerand route attributes), locale, security firewall, your listeners. Any listener may set a response and short-circuit.kernel.controller(andkernel.controller_arguments): the controller and its arguments are resolved (value resolvers,#[MapRequestPayload]); listeners can replace them.- The controller runs. If it returns something that isn't a
Response,kernel.viewlisteners turn it into one. kernel.response: listeners modify the response (headers, cookies, profiler toolbar, caching).kernel.finish_request, then the response is sent.kernel.terminate: work after sending (emails from the spool, slow logging).kernel.exception: if anything throws, listeners turn the exception into a response (error pages, API problem details).
See the built-in kernel events reference.
The fix
- Give listeners an explicit priority based on what must run before or after them. Maintenance should answer before routing and security, so use a priority higher than the router listener's.
- Check the real order in your app with
php bin/console debug:event-dispatcher kernel.request; priorities of built-in listeners can differ between versions and bundles. - Keep
kernel.requestlisteners cheap: they run on every request, including sub-requests (check$event->isMainRequest()).
Trade-offs
- Events make Symfony extremely flexible, but behaviour depends on ordering that isn't visible in the controller code. Document priorities next to the listener.
- Running before the firewall means your listener can't rely on the authenticated user.
Detect it in production
- The Symfony profiler's Events panel shows which listeners ran, in what order and which one set the response.
- Add functional tests for cross-cutting behaviour (maintenance, locale, auth) on several route types: unknown routes, protected routes, public routes.
Key points
- HttpKernel turns a request into events:
request,controller,controller_arguments,view,response,finish_request,terminate,exception. - Listeners run in priority order (higher first); setting a response short-circuits the rest.
- Use explicit priorities and verify them with
debug:event-dispatcher.
Practice questions
Name the main HttpKernel events in Symfony and when they fire.
Answer
kernel.request (before routing results are used and the controller is chosen), kernel.controller and kernel.controller_arguments (controller and arguments resolved), kernel.view (controller returned a non-Response), kernel.response (modify the response), kernel.finish_request, kernel.terminate (after sending) and kernel.exception (on errors).
How do listener priorities work in Symfony?
Answer
Listeners for the same event run from highest to lowest priority. A kernel.request listener that sets a response stops later listeners and the controller. Use debug:event-dispatcher to see the actual order.
What does kernel.view do?
Answer
If a controller returns something other than a Response (an array or an object), kernel.view listeners convert it into a Response, for example by serialising it or rendering a template.
Open in the app to mark lessons complete, take notes and track your progress.