Request Lifecycle
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. The middleware lists and the traced order below were produced by a new application, not copied from the documentation. Understanding the lifecycle answers most "where does this code go?" questions: which service provider method, which middleware, and what has (or has not) happened yet at each step. How PHP itself receives the request is on The PHP Request Model and Servers.
From index.php to the kernel
Every request enters through public/index.php. It checks whether storage/framework/maintenance.php exists (and if
so serves the maintenance response without booting anything), loads Composer’s autoloader, and requires
bootstrap/app.php, which creates the application: an instance of Illuminate\Foundation\Application, which is
also the service container that every other object is resolved from. Then:
<?php
// public/index.php (the end of the file)
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());
Request::capture() builds an Illuminate\Http\Request from the superglobals. It extends Symfony’s
HttpFoundation\Request (the trace below prints the parent class), so the whole of Symfony’s request and response
API (headers, cookies, files, content negotiation, streamed and binary file responses) is available, with Laravel’s
helpers on top. handleRequest() passes the request to the HTTP kernel (Illuminate\Foundation\Http\Kernel);
Artisan commands take the parallel path through the console kernel with handleCommand().
Bootstrapping and service providers
Before handling the request, the kernel runs its bootstrappers: it loads .env (unless the configuration is
cached), loads the configuration, installs the error and exception handlers, registers the facades, and then loads
the service providers. Providers are where the framework and the application set themselves up, in two passes:
-
register()on every provider: bind things into the container, nothing else. Other services may not exist yet. -
boot()on every provider: everything is registered, so boot code can use any service (define gates, rate limiters, view composers, model observers, macros, event listeners).
Laravel itself has dozens of providers (database, queue, validation, routing, …); the application’s own are listed in
bootstrap/providers.php. Deferred providers are only loaded when one of their services is first requested. More on
Service Container, Providers and Facades.
Middleware
The request then passes through middleware, layers that can inspect or change the request, stop it, or change the
response on the way back. There are two levels: global middleware run on every request, before routing; route
middleware run only for the matched route, either as a group (web, api) or individually. A new Laravel 13.34
application’s HTTP kernel reports these stacks:
<?php
// php artisan tinker --execute="..."
$kernel = app(Illuminate\Contracts\Http\Kernel::class);
echo "global:\n ", implode("\n ", $kernel->getGlobalMiddleware()), "\n";
echo "web group:\n ", implode("\n ", $kernel->getMiddlewareGroups()['web']), "\n";
echo "api group:\n ", implode("\n ", $kernel->getMiddlewareGroups()['api']), "\n";
global:
Illuminate\Http\Middleware\ValidatePathEncoding
Illuminate\Foundation\Http\Middleware\InvokeDeferredCallbacks
Illuminate\Http\Middleware\TrustProxies
Illuminate\Http\Middleware\HandleCors
Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance
Illuminate\Http\Middleware\ValidatePostSize
Illuminate\Foundation\Http\Middleware\TrimStrings
Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull
web group:
Illuminate\Cookie\Middleware\EncryptCookies
Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse
Illuminate\Session\Middleware\StartSession
Illuminate\View\Middleware\ShareErrorsFromSession
Illuminate\Foundation\Http\Middleware\PreventRequestForgery
Illuminate\Routing\Middleware\SubstituteBindings
api group:
Illuminate\Routing\Middleware\SubstituteBindings
Read from top to bottom, this is what Laravel does before your code runs: it rejects malformed path encoding, prepares
deferred callbacks, applies trusted-proxy headers, answers CORS preflights, returns 503 in maintenance mode, rejects
bodies larger than post_max_size, trims strings and turns empty strings into null. For web routes it then decrypts
cookies, starts the session, shares validation errors with the views, checks the CSRF token (PreventRequestForgery,
which replaced VerifyCsrfToken in Laravel 13) and resolves route model bindings. Because the session is started by
route middleware, global middleware cannot use the session, and the api group has no session or CSRF protection
at all. Each middleware is described on Middleware.
Routing and the controller
After the global middleware, the router matches the method and path against the registered routes (404 when nothing
matches, 405 when only the method is wrong), runs the matched route’s middleware, and calls the action: a controller
method or a closure. The container resolves the controller and injects its constructor and method dependencies; form
requests are validated and authorized at this point, before the method body runs. Whatever the action returns (a
view, an array, a model, a redirect, a Response) is converted into a response object.
The way back and after the response
The response travels back out through the route middleware and then the global middleware in reverse order, giving
each one the chance to modify it (adding headers, writing the session, queuing cookies). Then $response->send()
sends it to the client. Work can still happen after that:
-
terminable middleware: a
terminate($request, $response)method on a middleware runs after the response has been sent (with PHP-FPM, afterfastcgi_finish_request()); -
app()->terminating(...)callbacks; -
deferred functions:
Illuminate\Support\defer(fn () => …)runs a closure after a successful response (not after 4xx or 5xx, unless->always()), a lightweight alternative to a queued job.
The order, traced in a running application with a Trace::log() helper called from the provider, a global
middleware with terminate(), a route middleware, the route action and a terminating callback:
<?php
// bootstrap/app.php: register the two tracing middleware
return Application::configure(basePath: dirname(__DIR__))
// ... withRouting() and withExceptions() unchanged ...
->withMiddleware(function (Middleware $middleware): void {
$middleware->append(\App\Http\Middleware\TraceGlobal::class);
$middleware->alias(['trace.route' => \App\Http\Middleware\TraceRoute::class]);
})
->create();
<?php
// app/Http/Middleware/TraceGlobal.php (TraceRoute logs steps 4 and 6 the same way)
namespace App\Http\Middleware;
use App\Support\Trace;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class TraceGlobal
{
public function handle(Request $request, Closure $next): Response
{
Trace::log('3. global middleware, before');
$response = $next($request);
Trace::log('7. global middleware, after');
return $response;
}
public function terminate(Request $request, Response $response): void
{
Trace::log('8. global middleware terminate()');
}
}
<?php
// app/Providers/AppServiceProvider.php
namespace App\Providers;
use App\Support\Trace;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
Trace::log('1. AppServiceProvider::register()');
}
public function boot(): void
{
Trace::log('2. AppServiceProvider::boot()');
$this->app->terminating(fn () => Trace::log('9. terminating callback (after the response was sent)'));
}
}
Source: Laravel — Terminable Middleware and Service Providers
<?php
// routes/web.php
Route::get('/trace', function (Illuminate\Http\Request $request) {
App\Support\Trace::log('5. route action, request is a ' . get_parent_class($request));
return 'ok';
})->middleware('trace.route')->name('trace');
$ curl localhost:8000/trace && cat storage/logs/trace.log
ok
1. AppServiceProvider::register()
2. AppServiceProvider::boot()
3. global middleware, before
4. route middleware, before (route: trace)
5. route action, request is a Symfony\Component\HttpFoundation\Request
6. route middleware, after: status 200
7. global middleware, after
8. global middleware terminate()
9. terminating callback (after the response was sent)
Source: Laravel — Request Lifecycle and Deferred Functions
With php artisan serve, the log also showed one extra register()/boot() pair before the request’s: the serve
command itself boots the application once in the CLI before starting the server.
When the action fails
The same trace answers what happens when the action does not return normally. A second route registers two deferred
callbacks and then succeeds, aborts with a 403 or throws, and bootstrap/app.php reports exceptions to the trace
too:
<?php
// routes/web.php
Route::get('/trace/{outcome}', function (string $outcome) {
App\Support\Trace::log("5. route action ({$outcome})");
Illuminate\Support\defer(fn () => App\Support\Trace::log('10. defer(), only after a successful response'));
Illuminate\Support\defer(fn () => App\Support\Trace::log('10. defer()->always()'))->always();
return match ($outcome) {
'ok' => 'ok',
'denied' => abort(403),
'broken' => throw new RuntimeException('catalogue offline'),
};
})->middleware('trace.route')->name('trace.outcome');
// bootstrap/app.php, in withExceptions()
$exceptions->report(fn (Throwable $e) => \App\Support\Trace::log('5a. exception reported: '.$e->getMessage()));
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:8000/trace/ok; cat storage/logs/trace.log
200
1. AppServiceProvider::register()
2. AppServiceProvider::boot()
3. global middleware, before
4. route middleware, before (route: trace.outcome)
5. route action (ok)
6. route middleware, after: status 200
7. global middleware, after
10. defer(), only after a successful response
10. defer()->always()
8. global middleware terminate()
9. terminating callback (after the response was sent)
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:8000/trace/broken; cat storage/logs/trace.log
500
1. AppServiceProvider::register()
2. AppServiceProvider::boot()
3. global middleware, before
4. route middleware, before (route: trace.outcome)
5. route action (broken)
5a. exception reported: catalogue offline
6. route middleware, after: status 500
7. global middleware, after
10. defer()->always()
8. global middleware terminate()
9. terminating callback (after the response was sent)
Source: Laravel — Reporting Exceptions and Deferred Functions
The /trace/denied run (a 403) matched the 500 one except for the 5a line. Four things in the result are
easy to get wrong:
-
An exception does not skip the middleware on the way back. The router’s pipeline catches it at the innermost layer and turns it into a response with the exception handler, after reporting it (
5a), so the route and global middleware still ran their "after" code, and saw a500. A middleware that adds headers or writes an audit line on the way out therefore does it for errors as well; one that must not, checks the status. -
abort(403)was not reported.HttpExceptionandAuthorizationExceptionare on the handler’s internal "do not report" list, so a403or404is a response, not an incident. -
Deferred functions run only below status 400, so the plain
defer()ran for the200only, and the one marked->always()ran for all three. Both are invoked, and their exceptions reported and swallowed, by theterminate()method of the globalInvokeDeferredCallbacksmiddleware. -
That is also why
10came before8. The kernel callsterminate()on the global middleware in stack order, andInvokeDeferredCallbacksis second in that stack while the traced middleware was appended last. The terminating callback registered in the provider runs after all terminable middleware. Code that must run after a deferred function cannot rely on being a laterterminate(); it belongs in the same deferred closure.
Outside HTTP, deferred functions run when the Artisan command or queued job that registered them completes
successfully, so defer() in a service class behaves the same way wherever that class is used.
Under Octane
With Laravel Octane (FrankenPHP, Swoole or RoadRunner), the application is booted once per worker and kept in
memory: steps 1 and 2 above (the bootstrappers, register() and boot()) do not run per request, only the middleware,
routing and controller do. Octane resets the framework’s own request-scoped state between requests, but singletons
and static properties of the application persist, the same pitfall shown with plain FrankenPHP on
The PHP Request Model and Servers. Details are on
Octane and Performance.
Where older books and tutorials are outdated
-
The HTTP kernel class with
$middleware,$middlewareGroupsand$routeMiddleware(Stauffer ch. 10) no longer exists in new applications: the stacks are configured inbootstrap/app.php, and the defaults live in the framework (Laravel 11). -
VerifyCsrfTokenin thewebgroup isPreventRequestForgeryin Laravel 13, which also checksSec-Fetch-Site. -
RouteServiceProviderloading the route files and setting$namespace(Stauffer ch. 3) is replaced bywithRouting(); controllers are referenced by class, not by namespace strings. -
Doing work after the response required queues or
register_shutdown_function();defer()(Laravel 11) and terminable middleware cover the simple cases.