Skip to content
AXVI BUILD · WRITE · SHIP
Blog

Tracing a Request Through Laravel: Every Class, Every Hook

Follow a single HTTP request from public/index.php through the kernel, service providers, middleware pipeline, router, and back. With actual class names and method signatures from the Laravel source.

Tracing a Request Through Laravel: Every Class, Every Hook

Introduction

Every Laravel request starts in the same file and ends in the same way. Between those two points, your code passes through roughly fifteen classes, two pipelines, a service container resolution chain, and a router that does more work than you think.

Most developers know the high-level flow: request comes in, middleware runs, controller handles it, response goes out. But the actual execution path is richer — and understanding it gives you leverage. You know where to hook in, what to override, and why things break the way they do.

This article traces a single GET request from the moment it hits public/index.php to the moment the response leaves the server. Every class is real. Every method signature comes from the Laravel 12 source. No hand-waving.

Step 1: public/index.php

The entry point is deceptively simple:

use Illuminate\Http\Request;

define('LARAVEL_START', microtime(true));

require __DIR__.'/../vendor/autoload.php';

$app = require_once __DIR__.'/../bootstrap/app.php';

$app->handleRequest(Request::capture());

Three things happen:

  1. Composer's autoloader is registered
  2. The application instance is created via bootstrap/app.php
  3. handleRequest() takes over

Request::capture() creates a Symfony Request object from PHP superglobals ($_GET, $_POST, $_SERVER, $_COOKIE, $_FILES). From this point on, you never touch superglobals directly.

Step 2: bootstrap/app.php — Building the Application

This file creates and configures the Illuminate\Foundation\Application instance:

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
    )
    ->withMiddleware(function (Middleware $middleware) {
        // configure middleware
    })
    ->withExceptions(function (Exceptions $exceptions) {
        // configure exception handling
    })
    ->create();

Application::configure() returns an ApplicationBuilder — a fluent builder that sets up routing, middleware, and exception handling. create() returns the actual Application instance.

The Application class extends Container. It is the service container. Every app() call, every dependency injection, every resolve() — they all hit this single object.

Step 3: handleRequest()

Application::handleRequest() is where the framework starts working:

public function handleRequest(Request $request): void
{
    $kernel = $this->make(HttpKernelContract::class);

    $response = $kernel->handle($request);

    $response->send();

    $kernel->terminate($request, $response);
}

Four steps:

  1. Resolve the HTTP kernel from the container
  2. Handle the request — 99% of the work happens here
  3. Send the response — headers and body to output buffer
  4. Terminate — run terminable middleware after the response is sent

The terminate() step runs after the client has received the response. This is where you do cleanup, logging, session persistence — anything that shouldn't block the response.

Step 4: Kernel::handle() — The Bootstrap Sequence

Illuminate\Foundation\Http\Kernel::handle() orchestrates everything:

public function handle($request): Response
{
    $this->requestStartedAt = Carbon::now();

    $response = $this->sendRequestThroughRouter($request);

    $this->app['events']->dispatch(
        new RequestHandled($request, $response)
    );

    return $response;
}

Before the request reaches the router, the kernel bootstraps the application. This happens once per request (or once per boot in Octane):

protected $bootstrappers = [
    LoadEnvironmentVariables::class,
    LoadConfiguration::class,
    HandleExceptions::class,
    RegisterFacades::class,
    RegisterProviders::class,
    BootProviders::class,
];

Each bootstrapper runs in order:

  1. LoadEnvironmentVariables — parses .env into $_ENV
  2. LoadConfiguration — loads all config/ files into the config repository
  3. HandleExceptions — sets error and exception handlers
  4. RegisterFacades — registers the Facade autoloader so Cache::get() resolves
  5. RegisterProviders — calls register() on every service provider
  6. BootProviders — calls boot() on every service provider

The order matters. Providers are registered before they're booted. In register(), you can't safely use bindings from other providers. In boot(), everything is registered and ready.

Extension point

Your AppServiceProvider::boot() runs during BootProviders. Event listeners, route model bindings, view composers, macro definitions — all belong here.

Step 5: The Middleware Pipeline

This is where it gets interesting:

protected function sendRequestThroughRouter($request): Response
{
    $this->app->instance('request', $request);

    Facade::clearResolvedInstance('request');

    return (new Pipeline($this->app))
        ->send($request)
        ->through($this->middleware)
        ->then($this->dispatchToRouter());
}

Pipeline is one of Laravel's most elegant abstractions. It takes middleware classes and wraps them into a nested closure chain using array_reduce():

protected function carry(): Closure
{
    return function ($stack, $pipe) {
        return function ($passable) use ($stack, $pipe) {
            // Resolve middleware from container
            // Call $pipe->handle($passable, $stack)
        };
    };
}

This creates the onion pattern. With middleware A, B, C:

A (before) → B (before) → C (before) → Controller → C (after) → B (after) → A (after)

Each middleware receives $request and a $next closure. Calling $next($request) passes control to the next layer. Code before $next() runs inbound. Code after runs outbound.

Global vs route middleware

$this->middleware here is the global stack — runs on every request (TrimStrings, EncryptCookies, ValidatePostSize). Route-specific middleware runs later, inside the router.

Step 6: Router — Matching and Dispatching

The closure at the end of the pipeline resolves to Router::dispatch():

public function dispatch(Request $request): Response
{
    $route = $this->findRoute($request);

    $request->setRouteResolver(fn () => $route);

    $this->events->dispatch(new RouteMatched($route, $request));

    return $this->runRoute($request, $route);
}

findRoute() iterates the compiled route collection, matching URI and HTTP method against registered patterns. Route parameters are extracted and bound.

Route model binding

When your route uses {post} and your controller type-hints Post $post, implicit model binding happens in the SubstituteBindings middleware. It calls resolveRouteBinding() on the model — an Eloquent where()->firstOrFail() under the hood.

The second pipeline

runRoute() creates another Pipeline — with route-specific middleware:

protected function runRouteWithinStack(Route $route, Request $request): Response
{
    return (new Pipeline($this->container))
        ->send($request)
        ->through($this->gatherRouteMiddleware($route))
        ->then(fn ($request) => $this->prepareResponse(
            $request,
            $route->run()
        ));
}

Two pipelines total:

  1. Global (Kernel level) — TrimStrings, ValidatePostSize, EncryptCookies
  2. Route — auth, throttle, verified, your custom middleware

Step 7: Controller Dispatch

At the core of the onion, $route->run() executes your controller:

public function run(): mixed
{
    return $this->container->call(
        $this->getController(),
        $this->parametersWithoutNulls()
    );
}

Container::call() reads your controller method's type hints via Reflection, resolves each dependency from the container, merges with route parameters, and calls the method.

This is why you can type-hint Request $request, PostService $service, or any interface in controller methods — the container resolves them automatically via Reflection API.

Step 8: Response Preparation

Whatever the controller returns gets normalized by Router::prepareResponse():

public static function toResponse($request, $response): Response
{
    if ($response instanceof Responsable) {
        $response = $response->toResponse($request);
    }

    if (! $response instanceof SymfonyResponse) {
        $response = new Response($response, 200, ['Content-Type' => 'text/html']);
    }

    return $response->prepare($request);
}

Return an array — it becomes JsonResponse. Return a string — it's wrapped in Response. Return a view — it renders to HTML first.

The Responsable interface is an extension point: implement toResponse() on any object and return it from a controller.

Step 9: Unwinding the Onion

The response travels back through every middleware in reverse:

public function handle($request, Closure $next): Response
{
    // Before: runs inbound
    $response = $next($request);
    // After: runs outbound
    $response->header('X-Custom', 'value');
    return $response;
}

Route middleware unwinds first, then global middleware. Headers, cookies, and modifications accumulate on the way out.

Step 10: Sending the Response

Back in handleRequest():

$response->send();

Symfony's Response::send() writes headers via header() and echoes content. The client starts receiving data.

Step 11: Termination

$kernel->terminate($request, $response);

This runs terminate() on every terminable middleware, then fires registered callbacks:

public function terminate($request, $response): void
{
    $this->terminateMiddleware($request, $response);
    $this->app->terminate();
}

Terminable middleware handles post-response work: logging, analytics, flushing buffers. The client already has their response.

public function terminate(Request $request, Response $response): void
{
    // Log response time, flush metrics, close connections
}

The Complete Call Stack

public/index.php
  → Application::handleRequest()
    → Kernel::handle()
      → Bootstrap (env, config, providers, facades)
      → Pipeline (global middleware)
        → Router::dispatch()
          → findRoute() + RouteMatched event
          → Pipeline (route middleware)
            → SubstituteBindings (model binding)
            → Container::call() on controller
            → Response preparation
          ← Route middleware (after)
        ← Global middleware (after)
      ← RequestHandled event
    → Response::send()
    → Kernel::terminate()
      → Terminable middleware
      → Application::terminate()

What This Means for You

Understanding this flow gives you concrete advantages:

  1. Know where to hook in — service providers for boot-time setup, middleware for request/response modification, terminable middleware for post-response work
  2. Debug effectively — a 500 before middleware? Check bootstrapping. After middleware but before controller? Check route binding or route middleware.
  3. Optimize intelligently — global middleware runs on every request. Move expensive checks to route middleware where possible.
  4. Extend confidently — the Pipeline pattern works for any sequential processing. Use it in your own code.

The request lifecycle isn't magic. It's a well-designed sequence of container resolutions, pipeline passes, and event dispatches. Once you see it, you can't unsee it.


Found this useful? Follow me on GitHub, LinkedIn, or X.

Esc