You've written hundreds of Laravel routes. I have too. Quick test, then: can you name, in order, the things Laravel does between the moment a browser hits /users and the moment a JSON response leaves your server? Most of us can name four, maybe five if we squint. The framework is exceptionally good at hiding the rest. Let's stop hiding from it.
It all starts with one PHP file
Your web server (nginx, Caddy, FrankenPHP, whatever you've landed on) has exactly one PHP file it should ever execute. That file is public/index.php. Everything else in your repo (controllers, models, the migrations folder you keep meaning to clean up) is loaded because index.php ran first and pulled it in.
In Laravel 13, that file is about thirty lines. You can read the whole thing in one sitting. It checks for maintenance mode and short-circuits if your app is officially "down". It requires the Composer autoloader so every PSR-4 class becomes available. It bootstraps the application by including bootstrap/app.php. And then it hands the request off via $app->handleRequest(Request::capture()). That last call is the entire HTTP lifecycle, condensed into one method.
A small thing worth noticing: LARAVEL_START is captured before anything else. That's how php artisan about knows your boot time. It's also a free, accurate hook if you want to log how long requests took to even get into Laravel. Useful when you're hunting down whether a slow endpoint is your code or your infrastructure.
bootstrap/app.php is the new front door
If you came up on Laravel 8 or 9, you remember the constellation: App\Http\Kernel, App\Console\Kernel, App\Exceptions\Handler, plus a sprawling App\Providers folder. Middleware in one place, exceptions in another, the schedule in a third. It worked, but the entry point of your application was scattered across half a dozen files.
Laravel 11 collapsed all of that into a single fluent builder in bootstrap/app.php. Laravel 13 has trimmed it further. Today, your entire application skeleton lives in a single chained call.
Each ->with...() step registers configuration. It doesn't run anything yet. The ->create() at the end is where the Application instance is actually constructed and returned to index.php. If anyone ever asks you "where is the entry point of a Laravel app?", the honest answer is: this file, this method chain. Everything else is a leaf.
A couple of details worth noticing. The health: '/up' route is built-in. It returns a 200 with no DB or session work, perfect for load balancer probes. The withMiddleware() and withExceptions() callbacks receive configuration objects, not the actual middleware stack; they mutate state that the framework will use later, when it constructs the real pipeline. This deferred style is everywhere in Laravel 13. You describe what you want, the framework wires it up at the right moment.
Bootstrappers run in a very specific order
When handleRequest() is called, the first thing the HTTP Kernel does is bootstrap the application. "Bootstrap" here means: run a fixed list of classes, each of which does one tiny preparation step.
There are six bootstrappers, and they run in this exact order: LoadEnvironmentVariables reads .env and exposes it through env(). LoadConfiguration merges every file in config/ into a flat config repository. HandleExceptions registers the global error and exception handlers. RegisterFacades wires up the facade aliases (Auth, DB, Cache). RegisterProviders calls register() on every service provider. BootProviders calls boot() on every provider that was just registered.
This order is not arbitrary, and it cannot change without breaking the framework. Environment must load first because configuration files reference env(). Configuration must load second because exception handling needs to know whether you're in debug mode. Facades have to be registered before providers, because providers might use facades. And register() always runs before boot() for every provider. That's why your boot() method can safely depend on services other providers registered.
This last point trips up a lot of developers. If your provider's register() tries to resolve something another provider registers, it might or might not work depending on alphabetical loading order. If you do the same thing in boot(), it's safe. Every provider has already had its register() called by then. I have watched developers spend a full afternoon on "why doesn't my service work?" when the answer was: bound in boot(), resolved in register() by a deferred provider. The framework gave them no hint. The lifecycle did, if you knew where to look.
The middleware pipeline is an onion
Here's where things get clever. Laravel's middleware system is built on a tiny abstraction called the Pipeline, and it's the most elegant 80 lines of code in the framework.
Conceptually, middleware is a sequence: A → B → C → controller → C → B → A. Each middleware can do work before the request reaches the next layer, and after the response comes back. People call it the "Russian doll" or the "onion" model, depending on which mood they're in.
Naive implementations of this pattern get ugly fast. They tend to involve a stack, a counter, and a lot of if statements. Laravel's version uses a single array_reduce, builds a chain of nested closures inside-out, and then calls the outermost one. It's the kind of code you read once, raise an eyebrow at, and then never want to write yourself.
Read it slowly. The array_reduce walks the middleware list backwards, wrapping each one in a closure that knows how to call the next one. By the time it finishes, you have a single callable that, when invoked, runs everything in the right order. That's it. That's the whole machinery.
The practical implications are bigger than the implementation suggests. Order is everything: authentication has to come before authorization, which has to come before validation, which has to come before your controller. Swap two middlewares and you can introduce security bugs that test suites won't catch. Middleware can short-circuit by returning a response without calling $next($request), which is how rate limiting works, and how you can add a global feature flag in five lines. And the response flows backward through the same onion, which is why session middleware can save the session after the controller has finished writing to it.
A small, slightly playful observation: people love to write custom middleware. Most of those middlewares should have been Form Requests, Gates, or event listeners. If you reach for middleware to do business logic, pause and ask whether you're using it because it's the right tool, or because it's the only one you remembered exists at the right point in the lifecycle.
Routing happens after middleware, but only just
Here's a thing that confuses developers when they first learn it: route resolution happens inside the middleware pipeline, not before it. The global middleware runs first, then the framework's route-binding middleware does the actual route matching, route model binding, and controller resolution. After that, the route's own middleware runs. Then your controller runs.
Why is it layered like that? Because routing depends on having a fully-bootstrapped framework. You can't run route model binding without the database. You can't authorize a route without the user resolved. Putting the route lookup inside the pipeline guarantees all of those pieces are ready by the time it happens.
The dispatch step itself is short. It finds a matching route by URI and HTTP verb, runs the route's middleware in a sub-pipeline, and resolves the controller method. Matching uses a RouteCollection. Without route:cache, this is rebuilt from your route files on every request; with it, deserialised from a single PHP file. The performance difference is real (5-15ms on most apps), but only relevant once you're doing real volume.
Route model binding deserves its own paragraph. When you write Route::get('/posts/{post}', fn (Post $post) => ...), Laravel resolves $post automatically by calling Post::find($id). If the model isn't found, you get a 404 before your closure runs. This is convenient enough that almost no Laravel developer writes manual find() calls in controllers anymore. It's also the source of about thirty percent of "why is this query running?" surprises in slow endpoints.
You can customise resolution with Model::resolveRouteBinding(), scope it implicitly via parent-child relationships, and as of Laravel 13 attach native PHP attributes to control resolution behaviour. Sleeker than overriding methods, and consistent with the broader attribute-first direction the framework took this year. By the time your controller method runs, every parameter has been resolved, type-checked, and injected by the Service Container. Which is the topic of the next post.
The response travels back through the onion
Whatever your controller returns, Laravel needs to turn it into an HTTP response. There's a deceptively powerful step here called response preparation. If you return an array, Laravel JSON-encodes it. A string becomes the response body. A View is rendered to HTML. An Eloquent model is serialised through toArray(). A Responsable has its toResponse() called. This is how API Resources work. An actual Response passes through unchanged.
This normalisation happens in Router::prepareResponse(). Once you have a real Response, it travels back through the middleware pipeline in reverse order. Each middleware gets to inspect or modify the outgoing response. Session middleware writes the session. CSRF middleware refreshes the token cookie. Compression middleware gzips the body.
Then the response is sent to the browser via Response::send(). Network packets fly. You think it's over. It is not over.
After the response is sent, Laravel calls one more method on the kernel: terminate(). This is the most underused hook in the framework. It runs every "terminable" middleware (any middleware with a terminate() method) after the user has already received their response. Perfect for analytics, slow webhook fan-out, or any "fire and forget" work that doesn't need to block the response.
One caveat worth flagging: under Octane, terminate behaves differently. Workers don't die after each request, so anything you allocate in handle() survives. We will come back to Octane in the performance post.
The lifecycle, in fourteen steps
If you've made it this far, you can describe a Laravel request in fourteen reasonably short steps. Worth keeping somewhere: a sticky note, a mental model, the back of a notebook.
So what does this map get you?
If you can name those, you can debug almost any Laravel surprise. You can drop a dd() exactly where you need it. You can read framework PRs without flinching. You stop guessing about "where the magic happens", because you have already drawn the map.
Next time, we crack open the Service Container. It's the small, slightly mystical class that has been quietly resolving your controller arguments this whole post. It is where most of Laravel's magic actually lives, and (spoilers) it is simpler than you think.
It's not magic. It's just an order, and now you know it.