Introduction
Laravel feels simple from the outside.
You define a route, point it to a controller, return a view or JSON response, and the framework takes care of the rest. That simplicity is one of the best things about Laravel, but it can also hide the amount of work happening between the browser sending a request and your controller being executed.
That hidden path is the Laravel Request Lifecycle.
Understanding it makes a lot of things easier to reason about:
- why service providers matter so much
- why middleware can run before and after your controller
- why route model binding happens before your action is called
- why some code belongs in
register()and other code belongs inboot() - why terminable middleware runs after the response is already sent
- why long-running workers require more care with stateful services
In this article, let's do a deep dive into the Laravel Request Lifecycle from the first PHP file that receives the request all the way to the response being sent back to the browser.
We are going to focus on the HTTP lifecycle, because that is the path most developers think about when they say "request lifecycle". Console commands have a similar bootstrapping story, but they enter through the console kernel instead of the HTTP kernel.
The Big Picture
At a high level, a Laravel HTTP request goes through this flow:
flowchart TD
A[Web server receives request] --> B[public/index.php]
B --> C[Load Composer autoloader]
C --> D[Load bootstrap/app.php]
D --> E[Capture Illuminate HTTP Request]
E --> F[Application handles request]
F --> G[HTTP Kernel]
G --> H[Bootstrap framework]
H --> I[Global middleware]
I --> J[Router matches route]
J --> K[Route middleware]
K --> L[Controller or route closure]
L --> M[Response prepared]
M --> N[Middleware unwinds]
N --> O[Response sent]
O --> P[Termination callbacks]
The Laravel HTTP request lifecycle from entry point to termination.
That looks like a lot, but the mental model is simple:
Laravel builds the application, sends the request through a pipeline, runs the matched route, then sends the response back through that same pipeline.
The request travels inward through middleware before your controller runs. The response then travels outward through middleware before it is sent to the client.
This is why middleware is so powerful. It does not only guard access before your controller. It can also inspect or modify the response after your controller has finished.
Now let's walk the lifecycle piece by piece.
The Web Server Does Not Run Your Controller
When a browser requests /posts/laravel-request-lifecycle, Apache or Nginx does not know anything
about your route file, controller, middleware, service container, or Eloquent models.
The web server's job is much simpler: send the request to Laravel's front controller.
That front controller is:
public/index.php
This file is the entry point for all HTTP requests. In a modern Laravel application, it does three important things:
- loads Composer's autoloader
- retrieves the application instance from
bootstrap/app.php - asks the application to handle the captured request
Conceptually, it looks like this:
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());
Older Laravel applications, or applications upgraded across several major versions, may still show
the HTTP kernel explicitly in public/index.php:
$app = require_once __DIR__.'/../bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$response = $kernel->handle(
$request = Illuminate\Http\Request::capture()
)->send();
$kernel->terminate($request, $response);
Both versions express the same core idea: capture an HTTP request, pass it to Laravel, send the response, and terminate the request lifecycle.
The modern handleRequest() method simply centralizes that orchestration inside the application
instance.
Inside the framework, Illuminate\Foundation\Application::handleRequest() resolves the HTTP kernel,
lets it handle the request, sends the response, and then terminates the kernel:
public function handleRequest(Request $request)
{
$kernel = $this->make(HttpKernelContract::class);
$response = $kernel->handle($request)->send();
$kernel->terminate($request, $response);
}
This is the first important point: your controller is not the start of the request. By the time your controller runs, Laravel has already loaded configuration, registered providers, booted the application, matched the route, and executed several middleware layers.
Building the Application
The next important file is:
bootstrap/app.php
This file creates and configures the Laravel application instance. The application is also the service container, which means it is responsible for resolving dependencies throughout the entire lifecycle.
If you want to go deeper into that piece specifically, I wrote a full article about it here: Inside the Laravel Service Container.
In modern Laravel applications, bootstrap/app.php usually uses the fluent application builder:
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
// Configure middleware here.
})
->withExceptions(function (Exceptions $exceptions): void {
// Configure exception handling here.
})
->create();
This is where the application learns about its kernels, routes, middleware configuration, exceptions, and additional providers.
The important part is not the exact shape of the file. The important part is what this stage means:
Laravel now has an application container that knows how to resolve the framework services needed to process the request.
That container will be used everywhere:
- to resolve the HTTP kernel
- to instantiate middleware
- to instantiate controllers
- to inject dependencies into controller methods
- to resolve route binders
- to call terminating callbacks
So even before routing enters the conversation, the service container is already the backbone of the lifecycle.
Capturing the Request
Laravel wraps the incoming PHP request data in an Illuminate\Http\Request object.
This object extends Symfony's HTTP foundation request, and it gives Laravel a clean abstraction over things like:
- query string parameters
- request body input
- uploaded files
- cookies
- headers
- session access
- route parameters
- authenticated user resolution
The request is captured near the front controller with:
Request::capture();
From this point onward, Laravel passes a request object through the lifecycle instead of directly
working with PHP globals like $_GET, $_POST, $_FILES, and $_SERVER.
That matters because it gives the framework and your application a consistent object to pass through middleware, route matching, controller dispatching, validation, and testing.
In tests, this is one reason you can write:
test('the homepage loads', function (): void {
$this->get('/')->assertOk();
});
That test is not opening a real browser or making a real network call. Laravel creates a request internally and sends it through the same application lifecycle.
The HTTP Kernel
Once Laravel has an application and a request, the request is handed to the HTTP kernel.
The HTTP kernel is an implementation of:
Illuminate\Contracts\Http\Kernel
The framework implementation is:
Illuminate\Foundation\Http\Kernel
You can think of the HTTP kernel as the object that coordinates the request lifecycle for web requests.
Its handle() method receives a request and returns a response:
public function handle($request)
{
$this->requestStartedAt = Carbon::now();
try {
$request->enableHttpMethodParameterOverride();
$response = $this->sendRequestThroughRouter($request);
} catch (Throwable $e) {
$this->reportException($e);
$response = $this->renderException($request, $e);
}
$this->app['events']->dispatch(
new RequestHandled($request, $response)
);
return $response;
}
There are a few important things here.
First, the kernel enables HTTP method override. That is what allows HTML forms to simulate methods
like PUT, PATCH, and DELETE using hidden _method inputs.
Second, the kernel sends the request toward the router.
Third, if an exception escapes during the request, the kernel reports it and converts it into an HTTP response through the exception handler.
And finally, after the response is produced, Laravel dispatches a RequestHandled event.
So the kernel is not your business logic layer. It is the coordinator that makes sure the framework is bootstrapped, the request goes through middleware and routing, and exceptions become responses.
Bootstrapping the Framework
Before the router can dispatch the request, the kernel bootstraps the application.
In the framework, the HTTP kernel has a list of bootstrappers:
protected $bootstrappers = [
\Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables::class,
\Illuminate\Foundation\Bootstrap\LoadConfiguration::class,
\Illuminate\Foundation\Bootstrap\HandleExceptions::class,
\Illuminate\Foundation\Bootstrap\RegisterFacades::class,
\Illuminate\Foundation\Bootstrap\RegisterProviders::class,
\Illuminate\Foundation\Bootstrap\BootProviders::class,
];
These classes prepare the framework before your route is executed.
In a simplified way, this stage does things like:
- load environment variables
- load configuration
- configure exception handling
- register facades
- register service providers
- boot service providers
The application runs these bootstrappers with bootstrapWith():
public function bootstrapWith(array $bootstrappers)
{
$this->hasBeenBootstrapped = true;
foreach ($bootstrappers as $bootstrapper) {
$this['events']->dispatch('bootstrapping: '.$bootstrapper, [$this]);
$this->make($bootstrapper)->bootstrap($this);
$this['events']->dispatch('bootstrapped: '.$bootstrapper, [$this]);
}
}
This is one of those places where the container quietly does a lot of work. Each bootstrapper is
resolved from the container, then its bootstrap() method is called.
The most important part for application developers is the service provider phase.
Service Providers Are the Bootstrap Layer
Service providers are the central bootstrapping mechanism in Laravel.
They are responsible for registering and configuring large parts of the framework and your application:
- routing
- database services
- queue services
- validation
- events
- filesystem services
- package services
- application bindings
During the lifecycle, Laravel handles providers in two main phases.
First, it calls register() on providers.
Then, after all providers have been registered, it calls boot().
That order is extremely important.
The register() method should be used for binding things into the container:
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
use Illuminate\Support\ServiceProvider;
final class PaymentServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(PaymentGateway::class, StripePaymentGateway::class);
}
}
The boot() method should be used for work that depends on the rest of the application already
being registered:
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
final class AuthServiceProvider extends ServiceProvider
{
public function boot(): void
{
Gate::define('manage-billing', function (User $user): bool {
return $user->isBillingAdmin();
});
}
}
This is the rule I like to use:
Register bindings in register(). Configure already-registered services in boot().
If you try to configure routes, events, gates, or view composers too early in register(), you may
accidentally rely on a service that has not been registered yet.
Once the providers are booted, Laravel has a fully bootstrapped application and can start routing the request.
Sending the Request Through the Global Middleware Stack
After bootstrapping, the HTTP kernel sends the request through the global middleware stack before it reaches the router.
Inside the framework, that flow is represented by a pipeline:
return (new Pipeline($this->app))
->send($request)
->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)
->then($this->dispatchToRouter());
This is one of the most important pieces of the lifecycle.
The request does not go directly to the router. It first passes through global middleware. These are middleware that apply to every HTTP request in the application.
Depending on your application and Laravel version, this stack can include middleware for things like:
- invoking deferred callbacks
- trusting proxies
- handling CORS
- preventing requests during maintenance mode
- validating post size
- trimming strings
- converting empty strings to
null
This means global middleware is the right place for cross-cutting request behavior that should happen before route matching or before any route-specific decision.
For example, a middleware that attaches request context to logs could be global:
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
final class AddRequestContext
{
public function handle(Request $request, Closure $next): Response
{
Context::add('url', $request->fullUrl());
Context::add('trace_id', Str::uuid()->toString());
return $next($request);
}
}
Now every log written during the request can include the same request-level context.
Middleware Is an Onion
Middleware is easiest to understand as an onion.
Each middleware receives the request and decides whether to pass it deeper into the application:
public function handle(Request $request, Closure $next): Response
{
return $next($request);
}
Code before $next($request) runs on the way in:
public function handle(Request $request, Closure $next): Response
{
if (! $request->user()) {
return redirect()->route('login');
}
return $next($request);
}
Code after $next($request) runs on the way out:
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-App-Version', config('app.version'));
return $response;
}
That means middleware can do three different things:
- reject the request early
- modify the request before it reaches the route
- modify the response after the route has executed
This is why authentication, authorization checks, sessions, CSRF protection, request context, locale selection, and response headers fit naturally in middleware.
The order also matters. If middleware A wraps middleware B, then A sees the request first, but
it sees the response last.
sequenceDiagram
participant Client
participant A as Middleware A
participant B as Middleware B
participant C as Controller
Client->>A: Request
A->>B: Request
B->>C: Request
C-->>B: Response
B-->>A: Response
A-->>Client: Response
Middleware wraps the application on the way in and unwraps on the way out.
Once the request passes through the global middleware stack, Laravel dispatches it to the router.
The Router Finds the Matching Route
The router's job is to match the incoming request to a route and execute that route inside its own middleware stack.
The framework flow starts with Router::dispatch():
public function dispatch(Request $request)
{
$this->currentRequest = $request;
return $this->dispatchToRoute($request);
}
Then Laravel finds the route:
protected function findRoute($request)
{
$this->events->dispatch(new Routing($request));
$this->current = $route = $this->routes->match($request);
$route->setContainer($this->container);
$this->container->instance(Route::class, $route);
return $route;
}
There are a few details worth noticing.
First, Laravel dispatches a Routing event before the route is matched.
Second, it asks the route collection to match the request. This is where the HTTP method, domain, URI pattern, and constraints matter.
Third, once the route is found, Laravel stores the route in the container as the current route instance.
Then the router runs the route:
protected function runRoute(Request $request, Route $route)
{
$request->setRouteResolver(fn () => $route);
$this->events->dispatch(new RouteMatched($route, $request));
return $this->prepareResponse($request,
$this->runRouteWithinStack($route, $request)
);
}
This is why $request->route() works later in middleware, controllers, form requests, and other
parts of the application. The request has a route resolver attached to it.
Route Middleware Runs Before the Controller
After a route is matched, Laravel gathers the middleware assigned to that route.
This can include middleware from:
- route groups like
weborapi - middleware aliases like
auth - middleware attached directly to the route
- controller middleware
Then the router sends the request through another pipeline:
return (new Pipeline($this->container))
->send($request)
->through($middleware)
->then(fn ($request) => $this->prepareResponse(
$request,
$route->run()
));
This is where route-specific behavior happens.
For example, the web middleware group gives browser routes features like sessions and CSRF
protection. The api middleware group is usually lighter and focused on stateless API behavior.
This is also where route binding happens.
Laravel includes the SubstituteBindings middleware in the routing middleware priority list. That
middleware calls:
$this->router->substituteBindings($route);
$this->router->substituteImplicitBindings($route);
So when you write this route:
use App\Models\Post;
use Illuminate\Support\Facades\Route;
Route::get('/posts/{post:slug}', function (Post $post) {
return $post->title;
});
The controller or closure does not receive the raw {post} string. The binding middleware has
already replaced that route parameter with a Post model instance before your action runs.
That is an important lifecycle detail: route binding is not controller magic. It happens in the route middleware pipeline.
Running the Route Action
Once all route middleware have allowed the request to continue, Laravel runs the route action.
Inside Illuminate\Routing\Route, the simplified flow is:
public function run()
{
$this->container = $this->container ?: new Container;
try {
if ($this->isControllerAction()) {
return $this->runController();
}
return $this->runCallable();
} catch (HttpResponseException $e) {
return $e->getResponse();
}
}
If the route points to a controller, Laravel resolves the controller through the container:
public function getController()
{
if (! $this->controller) {
$class = $this->getControllerClass();
$this->controller = $this->container->make(ltrim($class, '\\'));
}
return $this->controller;
}
This is why constructor dependency injection works in controllers:
final class ShowInvoiceController
{
public function __construct(
private InvoiceRepository $invoices,
) {}
public function __invoke(string $invoice): View
{
return view('invoices.show', [
'invoice' => $this->invoices->findOrFail($invoice),
]);
}
}
The controller is not created with new ShowInvoiceController() in your route file. It is resolved
by the container, so Laravel can inspect its constructor and inject dependencies.
The same idea applies to many method dependencies. Form requests, for example, are resolved before your controller method runs. That is why this works:
final class StorePostController
{
public function __invoke(StorePostRequest $request): RedirectResponse
{
$post = Post::create($request->validated());
return redirect()->route('posts.show', $post);
}
}
The validation is not something your controller has to manually trigger. The request object is resolved, authorized, validated, and then passed to your controller.
By the time your controller body starts executing, the lifecycle has already done a lot of work for you.
Preparing the Response
Your route action can return many different things:
- a string
- an array
- an Eloquent model
- a view
- a redirect
- a JSON response
- an object implementing
Responsable - a Symfony response
Laravel normalizes those return values into a proper Symfony response object.
In the router, this happens through prepareResponse() and toResponse().
A simplified version of the conversion logic looks like this:
if ($response instanceof Responsable) {
$response = $response->toResponse($request);
} elseif (is_array($response)) {
$response = new JsonResponse($response);
} elseif (! $response instanceof SymfonyResponse) {
$response = new Response($response, 200, ['Content-Type' => 'text/html']);
}
return $response->prepare($request);
That is why all of these route actions are valid:
Route::get('/plain', fn () => 'Hello');
Route::get('/json', fn () => [
'status' => 'ok',
]);
Route::get('/custom', fn () => new InvoiceResponse($invoice));
Laravel turns the return value into an HTTP response that can travel back through the middleware stack and eventually be sent to the browser.
The Response Travels Back Out
Once the route action returns a response, the lifecycle reverses direction.
The response travels back outward through route middleware first, then global middleware.
That is why this middleware can add a response header after the controller runs:
final class AddSecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('X-Content-Type-Options', 'nosniff');
return $response;
}
}
The route action is done, but the response is still inside the middleware onion.
This matters when debugging because a response can be changed after your controller returns it.
If a header, cookie, redirect, session flash, or response body looks different than what your controller returned, middleware is one of the first places you should inspect.
Sending the Response
After the response has travelled back through the middleware stacks, the HTTP kernel returns it to the application entry point.
Then Laravel calls:
$response->send();
This sends the status code, headers, and content to the client.
At this point, the browser has the response. For the user, the request is basically complete.
But Laravel still has one final lifecycle phase.
Termination Happens After the Response Is Sent
After the response is sent, Laravel terminates the request lifecycle.
In Application::handleRequest(), this happens after send():
$response = $kernel->handle($request)->send();
$kernel->terminate($request, $response);
The HTTP kernel's terminate() method dispatches a terminating event, calls terminable middleware,
runs application termination callbacks, and handles request-duration callbacks:
public function terminate($request, $response)
{
$this->app['events']->dispatch(new Terminating);
$this->terminateMiddleware($request, $response);
$this->app->terminate();
// Request lifecycle duration handlers...
}
Terminable middleware can define a terminate() method:
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class RecordRequestMetrics
{
public function handle(Request $request, Closure $next): Response
{
return $next($request);
}
public function terminate(Request $request, Response $response): void
{
// Store metrics after the response was sent.
}
}
This is useful for work that should happen after the user receives the response, such as recording metrics or doing lightweight cleanup.
There is one subtle but important detail: when Laravel calls terminate() on middleware, it resolves
the middleware instance from the container. If you need the same middleware instance for handle()
and terminate(), register that middleware as a singleton.
But be careful. Post-response does not mean unlimited background processing. If the work is slow, unreliable, or depends on external services, a queued job is usually the better tool.
What Changes in Long-Running Workers
The traditional PHP lifecycle is simple: every request starts with a fresh process state and ends when the response is sent.
Long-running workers change that assumption.
With tools like Laravel Octane, the application can stay loaded in memory across multiple requests. Queue workers also process multiple jobs inside the same long-running PHP process.
That changes how you should think about state.
A singleton that feels harmless in a classic request can become dangerous if it stores request-level data:
final class CurrentTenant
{
public function __construct(
public ?int $tenantId = null,
) {}
}
If that is registered as a normal singleton in a long-running worker, one request could accidentally leave state behind for the next request.
For request or job specific services, prefer scoped bindings:
$this->app->scoped(CurrentTenant::class, function (): CurrentTenant {
return new CurrentTenant;
});
A scoped binding is resolved once within a lifecycle, then flushed when Laravel starts a new lifecycle, such as a new Octane request or a new queue job.
This is one of the practical reasons why understanding the lifecycle matters. It helps you decide how long objects should live.
Common Lifecycle Misunderstandings
Let's review a few misunderstandings that show up often.
"My controller is the start of the request"
It is not.
Your controller is near the middle of the lifecycle. Before it runs, Laravel has already bootstrapped the framework, loaded providers, passed through global middleware, matched the route, passed through route middleware, and resolved dependencies.
"Service providers run after routes"
Usually, no.
Providers are part of bootstrapping. Routing is registered and configured through providers and the application builder. The request is dispatched to the router after the application is bootstrapped.
"Middleware only runs before controllers"
Middleware can run before and after controllers.
Anything before $next($request) runs on the request path. Anything after it runs on the response
path.
"Route model binding happens inside the controller"
It does not.
Implicit and explicit route bindings are substituted before your controller is called, through the routing middleware pipeline.
"The request ends when the response is returned"
Not exactly.
Returning a response from a controller only starts the outward path. The response still needs to pass back through middleware, be sent to the client, and then the kernel termination phase runs.
A Practical Debugging Checklist
When something behaves strangely in a Laravel request, I like to debug it using the lifecycle order.
For example, if a request does not reach the controller, check:
- web server routing to
public/index.php - maintenance mode
- global middleware
- route registration
- route matching
- route middleware
- authorization middleware
- route model binding failures
- form request authorization or validation
If the controller returns the expected value but the browser receives something different, check:
- route middleware after
$next($request) - global middleware after
$next($request) - response macros or
Responsableobjects - exception rendering
- redirects created by validation, auth, or session middleware
If something behaves differently in queues or Octane, check:
- singleton services storing request state
- static properties
- cached resolved instances
- scoped bindings
- termination callbacks
- code that assumes a fresh PHP process per request
This is where lifecycle knowledge becomes practical. It gives you a map instead of forcing you to guess.
The Full Flow in One Example
Let's finish by tracing a request to this route:
use App\Http\Controllers\ShowPostController;
use Illuminate\Support\Facades\Route;
Route::get('/posts/{post:slug}', ShowPostController::class)
->middleware(['web', 'auth']);
When a user visits /posts/laravel-request-lifecycle, the lifecycle looks like this:
- The web server sends the request to
public/index.php. - Composer's autoloader is loaded.
- Laravel retrieves the application from
bootstrap/app.php. - Laravel captures the request as an
Illuminate\Http\Requestobject. - The application resolves the HTTP kernel.
- The kernel bootstraps the application.
- Environment variables and configuration are loaded.
- Exception handling and facades are configured.
- Service providers are registered.
- Service providers are booted.
- The request goes through global middleware.
- The router matches
GET /posts/{post:slug}. - Laravel attaches the matched route to the request.
- Route middleware runs.
SubstituteBindingsresolves{post:slug}into aPostmodel.authverifies the user.- The controller is resolved from the container.
- Controller dependencies are injected.
- The controller method runs.
- The controller returns a response-like value.
- Laravel converts it into a proper response.
- The response travels back through route middleware.
- The response travels back through global middleware.
- The response is sent to the browser.
- Terminable middleware and application termination callbacks run.
That is the request lifecycle in practical terms.
Not magic. Just a well-organized pipeline.
Conclusion
The Laravel Request Lifecycle is one of the best things to understand if you want to feel more confident working with the framework.
Once you understand the path from public/index.php to the HTTP kernel, from bootstrappers to
service providers, from middleware to routing, and from controller responses to termination, Laravel
starts feeling much less magical.
And that is the real benefit.
You do not need to memorize every internal class to be productive. But you should know where things happen:
- application setup happens in
bootstrap/app.php - framework preparation happens in the kernel bootstrappers
- application and package configuration happens in service providers
- cross-cutting HTTP behavior happens in middleware
- route matching and binding happen before your controller runs
- response normalization happens after your route action returns
- termination happens after the response is sent
With that mental model, debugging gets easier, architecture decisions get clearer, and Laravel's convenience becomes something you can trust because you understand what is behind it.
I hope that you liked this article and if you do, don't forget to share this article with your friends!!! See ya!