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 /account are 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.

Listener priorities on kernel.requestTry it yourself
<?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

  1. public/index.php boots the Kernel (container compiled and cached in production) and calls $kernel->handle($request).
  2. kernel.request: listeners run in priority order: session, routing (sets _controller and route attributes), locale, security firewall, your listeners. Any listener may set a response and short-circuit.
  3. kernel.controller (and kernel.controller_arguments): the controller and its arguments are resolved (value resolvers, #[MapRequestPayload]); listeners can replace them.
  4. The controller runs. If it returns something that isn't a Response, kernel.view listeners turn it into one.
  5. kernel.response: listeners modify the response (headers, cookies, profiler toolbar, caching).
  6. kernel.finish_request, then the response is sent.
  7. kernel.terminate: work after sending (emails from the spool, slow logging).
  8. 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.request listeners 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.