Request Lifecycle: How Web Frameworks Handle a Request · PHP Frameworks: Laravel, Octane and Symfony

The Laravel Request Lifecycle

Why even the health-check endpoint took 120 ms, and how Laravel's bootstrap, service providers, middleware and terminate phase fit together.

Scenario

Example app: a Laravel SaaS added a SettingsServiceProvider. Its boot() method loads all tenant settings from the database and calls a feature-flag API, so settings are "always available".

What you see in production

  • Every request got about 120 ms slower, including /up (the health check), static-like JSON endpoints and webhooks that never read settings.
  • The feature-flag provider's rate limit is hit during traffic peaks, because it's called on every request.
  • Profiling shows the time is spent before any middleware or controller runs.

The naive code

class SettingsServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $settings = Setting::all()->pluck('value', 'key');      // DB query on every request
        $flags = Http::get('https://flags.example.com/all')->json(); // network call on every request
        $this->app->instance('settings', $settings->merge($flags));
    }
}

Reproduce it

The simulation walks the Laravel lifecycle for two requests, once with the eager provider and once with the work moved to a lazily resolved, cached service.

Where the time goes in the Laravel lifecycleTry it yourself
<?php
function lifecycle(string $path, bool $eagerSettings, bool $trace): int {
    $ms = 0;
    $step = function (string $text, int $cost = 0) use (&$ms, $trace) {
        $ms += $cost;
        if ($trace) echo "  $text" . ($cost ? " (+{$cost} ms)" : "") . "\n";
    };
    $step("public/index.php: autoloader, create app from bootstrap/app.php", 1);
    $step("HTTP kernel bootstrappers: env, config, exception handling, facades", 2);
    $step("register() every service provider", 1);
    $step("boot() every service provider" . ($eagerSettings ? " - SettingsServiceProvider queries DB + flag API" : ""),
        $eagerSettings ? 120 : 2);
    $step("-> global middleware (trusted proxies, maintenance mode, trim strings)", 1);
    $step("router matches $path, applies route middleware group");
    if ($path === "/checkout") {
        $step("-> route middleware: auth, verified", 3);
        $step("controller resolved from the container; settings service resolved now (cached)", $eagerSettings ? 0 : 4);
        $step("controller runs", 25);
    } else {
        $step("controller runs", 1);
    }
    $step("<- response travels back out through the same middleware (in reverse)");
    $step("response sent to the client");
    $step("terminate(): terminable middleware and app terminating callbacks run after sending");
    return $ms;
}

echo "trace of GET /up with the eager provider:\n";
lifecycle("/up", true, true);
foreach (["/up", "/checkout"] as $path) {
    printf("%-10s eager provider: %3d ms | lazy cached service: %3d ms\n", $path, lifecycle($path, true, false), lifecycle($path, false, false));
}

Output

trace of GET /up with the eager provider:
  public/index.php: autoloader, create app from bootstrap/app.php (+1 ms)
  HTTP kernel bootstrappers: env, config, exception handling, facades (+2 ms)
  register() every service provider (+1 ms)
  boot() every service provider - SettingsServiceProvider queries DB + flag API (+120 ms)
  -> global middleware (trusted proxies, maintenance mode, trim strings) (+1 ms)
  router matches /up, applies route middleware group
  controller runs (+1 ms)
  <- response travels back out through the same middleware (in reverse)
  response sent to the client
  terminate(): terminable middleware and app terminating callbacks run after sending
/up        eager provider: 126 ms | lazy cached service:   8 ms
/checkout  eager provider: 153 ms | lazy cached service:  39 ms

Root cause

In a standard PHP-FPM deployment, Laravel bootstraps the whole application on every request. All service providers run register() and then boot() before routing even starts. Expensive work in boot() is therefore paid by every request, whether or not it needs the result.

The lifecycle, step by step

From Laravel's request lifecycle documentation:

  1. Entry: public/index.php loads Composer's autoloader and the application instance from bootstrap/app.php.
  2. Kernel bootstrapping: the HTTP kernel loads environment and configuration, sets up error handling and facades, then registers all service providers and boots them. Providers wire the framework (database, queue, routing, validation) and your app into the service container.
  3. Global middleware: the request passes through middleware that runs for every request.
  4. Routing: the router matches a route, applies its middleware group (web: sessions, cookies, CSRF; api: stateless) and route middleware (auth, throttle), and resolves route-model bindings.
  5. Controller: resolved from the container with dependencies injected; its return value (view, array, model, Response) is converted to a response.
  6. Response: travels back out through the same middleware in reverse order (the "onion"), so middleware can modify it, then is sent.
  7. Terminate: terminable middleware (terminate() method) and app()->terminating() callbacks run after the response is sent (on PHP-FPM, after fastcgi_finish_request).

The fix

  • Keep register() to bindings and boot() cheap: no queries or HTTP calls in providers.
  • Bind expensive services lazily (a singleton closure resolves only when first used) and cache their data (Cache::remember), refreshing on change.
  • Use deferred providers (implement DeferrableProvider) for providers that only bind services, so they load only when one of those services is requested.
  • In production run php artisan optimize (config, routes, events and views caching) so bootstrap doesn't parse files on each request.
  • Use terminable middleware or queued jobs for work that doesn't need to block the response.

Trade-offs

  • Caching settings means changes take effect after the cache is invalidated; invalidate on save.
  • Long-running servers (Octane) avoid repeated bootstrap entirely but bring their own risks (next lesson).

Detect it in production

  • Profile requests (Laravel Telescope, Debugbar locally, APM in production) and look at time before the first middleware.
  • Compare the latency of a trivial endpoint (/up) with the network round trip: if a no-op takes 100 ms, bootstrap is the culprit.

Key points

  • Laravel bootstraps the app and boots all providers on every request (under PHP-FPM).
  • Order: index.php → kernel bootstrap → providers register/boot → global middleware → router and route middleware → controller → response back through middleware → send → terminate.
  • Keep providers cheap; resolve and cache expensive services lazily; use php artisan optimize.

Practice questions

Walk through the lifecycle of an HTTP request in Laravel.

Answer

public/index.php loads the autoloader and app; the HTTP kernel bootstraps (env, config, error handling, facades) and registers then boots all service providers; the request passes global middleware; the router matches a route and runs its middleware group and route middleware; the controller runs; the response goes back through middleware in reverse and is sent; terminable middleware runs afterwards.

Why should a service provider's boot() method not run database queries or HTTP calls?

Answer

Under PHP-FPM every request boots every provider before routing, so the cost is paid by all requests, even those that never need the data. Resolve such services lazily and cache their data.

What is terminable middleware in Laravel?

Answer

Middleware with a terminate($request, $response) method that runs after the response has been sent to the client, useful for work such as logging that shouldn't delay the response.

Open in the app to mark lessons complete, take notes and track your progress.