Building Web Apps with Laravel Module 3 – Requests, routing and middleware
How Laravel handles a request
Follow one Laravel 13 request from public/index.php through the HTTP kernel, providers, middleware and router to the response, with a run that traces it.
What you will learn
- Trace a request from public/index.php through the kernel, the middleware and the router to the response
- List the global middleware and the web and api groups of a new Laravel 13 project, and say which requests each one wraps
- Explain why every provider's register method runs before any provider's boot method
- Work out the order of the before and after steps of nested middleware
- Choose between terminable middleware, defer() and a queued job for work that can wait until after the response
Before you start
What you’ll learn · Before you start
What you will learn
- Trace a request from public/index.php through the kernel, the middleware and the router to the response
- List the global middleware and the web and api groups of a new Laravel 13 project, and say which requests each one wraps
- Explain why every provider's register method runs before any provider's boot method
- Work out the order of the before and after steps of nested middleware
- Choose between terminable middleware, defer() and a queued job for work that can wait until after the response
Before you start
On this page
When a visitor opens /notices on Pathshala’s site, 14 middleware classes of a new Laravel 13 project look at the request before your controller runs: 8 that see every request and 6 more that wrap only the routes of routes/web.php. When a request behaves strangely, such as a form field that arrives as null, a cookie that appears on one URL and not on another, or a log line that prints twice, the cause sits somewhere on this path.
You can only find it if you know the order. This lesson follows one request from the front door to the response and then past it, to the work that runs after the response has gone. You will print the real middleware lists of Laravel 13.34, trace a request through providers and middleware you write, and decide how to run work that can wait.
One front door sends every request through the kernel
Every request that is not a real file reaches a Laravel application through one file, public/index.php. The web server is set up to send it there, so there is a single entry point to protect, to log and to reason about. That file does very little: it loads Composer’s autoloader, asks bootstrap/app.php for the application, and hands the request over with $app->handleRequest(Request::capture()). The application object it receives is also the service container: The object that builds and stores the classes an application needs. Laravel's application object is the container, and providers put their bindings into it., the object that builds and stores the classes your code asks for.
The request then goes to the HTTP kernel: The class that runs a web request from start to finish: it boots the framework, sends the request through the middleware and the router, and returns a response., the class that runs a request from start to finish. Its handle method takes a request and returns a response, and between the two it boots the framework, pushes the request through the middleware and asks the router for a route. Follow one GET /notices through the whole path:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- The web server hands over. Nginx, Apache or the development server sends every request that is not a real file to public/index.php. That one file is the front controller, so there is only one door into the application.
- The application is built. index.php loads Composer's autoloader and asks bootstrap/app.php for the application. That file sets the routing files, the middleware and the exception rules, and returns the application object, which is also the service container.
- The kernel takes the request. index.php passes the request to the HTTP kernel through handleRequest. The kernel first runs its bootstrappers, which load the environment and configuration and then register and boot the service providers.
- Global middleware wraps it. The request goes through the global middleware, which sees every request, even one that matches no route. Each middleware may change the request, stop it with a response of its own, or pass it inward.
- The router chooses a route. Inside the global layer the router matches the method and path against the route files. A match brings that route's own middleware, such as the web group, and a miss becomes a 404 response.
- Your controller runs. The route's closure or controller action runs and returns something, a view, an array or a response object. Laravel turns whatever it returned into a response.
- The response travels back out. The response passes back out through the same middleware, in the opposite order, so each one can add headers or change the body on the way out.
- Terminate runs last. After the response is sent, the kernel calls terminate. Terminable middleware and deferred functions run here, so the visitor is not kept waiting for them.
The figure shows the part of the path that does not change from one application to another. What you control is what you put into it: the routing files and middleware that bootstrap/app.php registers, the providers listed in bootstrap/providers.php, and the controller that finally answers. The documentation calls this whole journey the request lifecycle: The path one request takes through an application: the front controller, the kernel, the middleware, the router and the controller, then the response and the work that runs after it..
Find the front door
Open public/index.php in your Pathshala project and find the two lines that matter: the require_once of bootstrap/app.php, and the call to handleRequest. Everything else in the file is housekeeping.
Global middleware sees every request and a group sees only its routes
A middleware: A class that sits between the web server and the controller. It can inspect or change a request, stop it with its own response, or pass it inward and then change the response on the way out. is a class that sits between the web server and your controller. It receives the request, can look at it or change it, and then either passes it inward or stops it with a response of its own. The kernel keeps one list of global middleware: A middleware in the HTTP kernel's own list. It wraps every request, including one that matches no route., which wraps every request, and the router keeps named middleware group: A named list of middleware, such as web or api, that the router attaches to every route loaded into the group., which wrap only the routes they are attached to. Print what a new Laravel 13.34 project holds:
from session import run
# Print the global middleware, the groups and the aliases of a new Laravel 13 project.
run(
"""
php middleware_stack.php
""",
app=True,
overlay=("stack-probe",),
) Output
$ php middleware_stack.php global, in order: ValidatePathEncoding InvokeDeferredCallbacks TrustProxies HandleCors PreventRequestsDuringMaintenance ValidatePostSize TrimStrings ConvertEmptyStringsToNull group web, in order: EncryptCookies AddQueuedCookiesToResponse StartSession ShareErrorsFromSession PreventRequestForgery SubstituteBindings group api, in order: SubstituteBindings aliases: auth Authenticate auth.basic AuthenticateWithBasicAuth auth.session AuthenticateSession cache.headers SetCacheHeaders can Authorize guest RedirectIfAuthenticated password.confirm RequirePassword precognitive HandlePrecognitiveRequests signed ValidateSignature throttle ThrottleRequests verified EnsureEmailIsVerified
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stack_session.py
Read the output top down. Eight global middleware run first, in this order: ValidatePathEncoding, InvokeDeferredCallbacks, TrustProxies, HandleCors, PreventRequestsDuringMaintenance, ValidatePostSize, TrimStrings and ConvertEmptyStringsToNull. The last two change input before your code sees it, so a name sent as " Meera " arrives as "Meera" and an empty text box arrives as null. The requests lesson shows what that does to your validation rules.
The web group adds six more, for pages meant for browsers: EncryptCookies, AddQueuedCookiesToResponse, StartSession, ShareErrorsFromSession, PreventRequestForgery and SubstituteBindings. The api group holds only SubstituteBindings, which is why a JSON API has no session and no cookies unless you add them. The aliases at the end are short names, such as throttle and auth, that you can attach to a route without writing the class. The routing lesson and the middleware lesson of this module use them.
Which requests does the web group wrap? Only those that reach a route registered in routes/web.php. Start the development server and ask for three URLs to see the difference:
from session import serve
# Real HTTP through public/index.php: a page in the web group, the health check and a path no route handles.
serve(
"""
curl -s -D - -o /dev/null http://127.0.0.1:8311/
curl -s -D - -o /dev/null http://127.0.0.1:8311/up
curl -s -D - -o /dev/null http://127.0.0.1:8311/missing
""",
app=True,
port=8311,
) Output
# Terminal 1: start the server $ php artisan serve --host=127.0.0.1 --port=8311 --no-reload # Terminal 2: send requests $ curl -s -D - -o /dev/null http://127.0.0.1:8311/ HTTP/1.1 200 OK Host: 127.0.0.1:8311 Connection: close X-Powered-By: PHP/8.5.11 Content-Type: text/html; charset=utf-8 Cache-Control: no-cache, private Date: … Set-Cookie: XSRF-TOKEN=…; expires=…; Max-Age=…; path=/; samesite=lax Set-Cookie: laravel-session=…; expires=…; Max-Age=…; path=/; httponly; samesite=lax $ curl -s -D - -o /dev/null http://127.0.0.1:8311/up HTTP/1.1 200 OK Host: 127.0.0.1:8311 Connection: close X-Powered-By: PHP/8.5.11 Cache-Control: no-cache, private Date: … Content-Type: text/html; charset=utf-8 $ curl -s -D - -o /dev/null http://127.0.0.1:8311/missing HTTP/1.1 404 Not Found Host: 127.0.0.1:8311 Connection: close X-Powered-By: PHP/8.5.11 Cache-Control: no-cache, private date: … Content-Type: text/html; charset=utf-8 # Terminal 1 showed this, then Ctrl+C stopped the server: INFO Server running on [http://127.0.0.1:8311]. Press Ctrl+C to stop the server … / ................................................. ~ … … /up ............................................... ~ … … /missing .......................................... ~ …
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 front_door_session.py
The home page answers with two Set-Cookie headers, XSRF-TOKEN and laravel-session, the work of the web group’s cookie and session middleware. The health route /up and the unknown /missing answer without any cookie, although both responses still pass through the global middleware. The skeleton registers /up outside the web group, so a monitoring check does not start a session for each ping.
Checkpoint
Check: One front door and the kernel · Global middleware and middleware groups
Providers register everything before they boot anything
A service provider: A class that sets one part of the application up. Its register method binds services into the container, and its boot method uses them once every provider has registered. is a class that sets one part of the application up. Nearly every feature of the framework, from the database to validation, arrives through a provider, and your application lists its own in bootstrap/providers.php. Each provider has two methods with different jobs: register only binds things into the container, and boot uses them. The kernel calls register on every provider first and boot on each of them afterwards.
To watch the two phases, the project below lists two providers. AppServiceProvider is first in the list, and its register method tries to build a FeeCalendar, an interface that only the second provider binds. Its boot method asks for the same calendar a moment later. A small Trace class prints one numbered line for each event, and the harness sends two requests through the real kernel:
from session import run
# One request that matches a route and one that does not, with every step of the lifecycle traced.
run(
"""
php trace_request.php /notices /missing
""",
app=True,
overlay=("trace-probe",),
) Output
$ php trace_request.php /notices /missing == GET /notices 1 AppServiceProvider::register 2 register() cannot use a later binding: BindingResolutionException 3 PathshalaServiceProvider::register binds FeeCalendar 4 AppServiceProvider::boot, next due day is the 5th of next month 5 PathshalaServiceProvider::boot 6 middleware global: before 7 middleware web group: before 8 controller: building the notice list 9 middleware web group: after, status 200 10 middleware global: after, status 200 11 response sent 12 deferred callback runs 13 AuditAfterResponse::terminate, status 200 == GET /missing 14 middleware global: before 15 middleware global: after, status 404 16 response sent 17 AuditAfterResponse::terminate, status 404
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 trace_session.py
Lines 1 to 5 belong to the providers. Line 2 is the failure: while the first provider registers, nothing has bound FeeCalendar, so the container throws a BindingResolutionException. Line 4 is the same request for the calendar one phase later, and it works, because line 3 has registered the second provider in between. Both providers ran during the first request, because the kernel boots the application when it first handles one.
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- The first register. The kernel creates both providers, then calls register on the first one. That register tries to use the fee calendar, which only the second provider binds, and the container answers with a BindingResolutionException.
- The second register. The kernel calls register on the second provider. It binds the FeeCalendar interface to MonthlyCalendar, so the container now knows how to build one.
- The first boot. Every provider has registered, so the kernel starts the boot calls. The first provider asks for a FeeCalendar and gets one, although it was listed before the provider that binds it.
- The second boot. The second provider boots last. Its boot may rely on any binding made by any provider, which is why routes, observers, gates and macros belong in boot and not in register.
This is why routes, gates, event listeners and macros belong in boot. A register method that touches a service another provider supplies works or fails depending on the order of the list, and that kind of bug appears only after someone reorders a file.
Key idea
register is for binding and boot is for using. Because all the register calls finish before the first boot starts, a boot method can rely on every binding of every provider.
Middleware nests, so the last to go in is the first to come out
Now look at lines 6 to 13 of the trace, the request for /notices. Two copies of one Trace middleware are registered. The first is global, added with $middleware->append(...). The second is added to the web group with $middleware->web(append: [...]). A text after a colon, as in Trace::class.':global', reaches the middleware as an extra argument, which is how one class prints two different labels.
The prints come in the shape of nested function calls. Each middleware prints its “before” line, calls $next($request) to run everything deeper, and prints its “after” line when that call returns. Replay the lines one at a time:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Global middleware, going in. The first middleware of the kernel's list prints its before line and calls the next one. Nothing deeper has run yet, so this line comes first.
- The group, going in. The route matched, so the web group's middleware runs inside the global layer. Its before line is second because it is nested one level deeper.
- The controller answers. The innermost layer is the route's closure. It builds the notice list and returns a response with status 200.
- The group, coming out. Control returns to the web group first. It sees the response and its status, which is why its after line is printed before the global one.
- Global middleware, coming out. The outermost layer sees the response last. Middleware around middleware behaves like nested function calls, so the last to go in is the first to come out.
- The response is sent. handle has returned, so the response goes to the browser. Everything printed so far happened while the visitor was waiting.
- Terminate and deferred work. The kernel calls terminate. The deferred callback runs first and the audit middleware's terminate method second, because the kernel terminates its list in the order it was listed.
The global middleware printed first on the way in and last on the way out, because it is the outermost layer. The web group printed second in and second out. The controller, line 8, ran at the centre. Any middleware that adds a header to the response does so on the way out, after its $next call, and the layers nearest to the controller get the first chance to change it.
A middleware that never calls $next ends the request at its own layer. It returns a response itself, as an authentication middleware does when it redirects a visitor to the login page. Nothing deeper runs, and every layer around it still prints its “after” line, because they are still waiting for the result of their own $next call. The /missing request in the trace shows the same shape: the router found no route, so the controller layer and the web group are absent, while the global pair prints status 404.
Writing a response header before $next
A middleware cannot set a response header before it calls $next($request), because the response does not exist yet. Call $next($request) first, keep its result in $response, set the header on that object and return it.
Checkpoint
Check: Register, then boot · Nested middleware and the order of before and after
Work that can wait runs after the response with terminate or defer
Some work does not need to finish before the visitor sees the page: writing an audit line, recording a metric, sending a statistics ping. Laravel gives two places to put it that run after the response has been sent. The first is a terminable middleware: A middleware with a terminate method, which the kernel calls once the response has been sent, so slow work does not make the visitor wait., which has a terminate method that the kernel calls once the response has gone. In the trace it is AuditAfterResponse. The second is defer(), which takes a closure, the deferred function: A closure passed to defer(). It runs after the response has been sent, and by default only when the response status is below 400., and runs it at the same moment.
Lines 12 and 13 of the trace show both at work. The deferred callback printed first and the middleware’s terminate second, because the deferred callbacks are run by a global middleware that sits earlier in the list than the audit middleware. The documentation notes that terminate is called automatically when the web server uses FastCGI, which is how most production PHP runs. The harness above calls it by hand, as handleRequest does, so it is the real kernel but not a web server.
There is a catch. A deferred function runs only when the response is successful, below status 400, unless you chain always(). Ask for a route that defers two callbacks, one of them with always(), and answers 403:
from session import run
# A route that answers 403 after it deferred two callbacks, one of them with always().
run(
"""
php trace_request.php /closed
""",
app=True,
overlay=("trace-probe",),
) Output
$ php trace_request.php /closed == GET /closed 1 AppServiceProvider::register 2 register() cannot use a later binding: BindingResolutionException 3 PathshalaServiceProvider::register binds FeeCalendar 4 AppServiceProvider::boot, next due day is the 5th of next month 5 PathshalaServiceProvider::boot 6 middleware global: before 7 middleware web group: before 8 middleware web group: after, status 403 9 middleware global: after, status 403 10 response sent 11 always-deferred callback runs 12 AuditAfterResponse::terminate, status 403
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 after_response_session.py
Line 8 shows the web group’s “after” with status 403, and the plain deferred callback never prints. Only the callback marked always() runs, on line 11, and the audit middleware’s terminate still sees the 403 on line 12. A closure that reports a metric usually wants always() for exactly this reason: a failed request is the one you most want to count.
Neither of the two retries. The kernel runs every deferred function inside rescue(), which reports an exception to the log and moves on, so a failed callback is dropped. Work that must not be lost belongs in a queued job, which a separate worker process can retry. Choose by answering the two questions of the table:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Way | Where it runs | On a 4xx or 5xx response | If it throws | Needs a worker |
|---|---|---|---|---|
| defer() What it is: a closure run after the response, in the same process | Good after the response, by the same PHP process | Mixed skipped unless you chain always() | Poor the error is reported and the callback is dropped, with no retry | Good none |
| Terminable middleware What it is: a terminate method that every matched request calls | Good after the response, for every request that passes the middleware | Good runs, and receives the error response to inspect | Poor the request has ended and nothing retries the work | Good none |
| Queued job What it is: a job that a separate worker process picks up | Mixed when a worker takes it, often a moment later | Good runs when the code that dispatched it chose to dispatch | Good the job can retry, and a failure is recorded | Poor a long-running worker must be started and kept alive |
How the answers decide
- “Must the work be tried again if it fails?” Yes, losing it is not acceptable: rules out defer() and Terminable middleware. Neither runs again after an error, so work that must not be lost needs a queue and its retries.
- “Must the work be tried again if it fails?” No, losing it is acceptable and “Does the work belong to every request, or to one route?” Every request that gets through: adds a caveat to defer(). A callback must be deferred from code on each path, so one middleware with a terminate method is the simpler place for work every request needs.
- “Does the work belong to every request, or to one route?” One route or action: adds a caveat to Terminable middleware. A terminable middleware can be attached to a single route, but a defer call beside the code it follows is easier to read.
Key idea
A terminable middleware and a deferred function run after the visitor has the response, in the same process, with no retry. A queued job is the choice when the work must survive a failure.
Checkpoint
Check: Work after the response
Practise
Trace a request in your own Pathshala project, and check each step with the files you wrote:
- Create a middleware with
php artisan make:middleware LogRequest, and make it write one line before$nextand one after, each with the HTTP method and the path. - Register it as global middleware with
$middleware->append(LogRequest::class)inbootstrap/app.php, then add a second copy to thewebgroup with$middleware->web(append: [LogRequest::class]). Request/notices,/upand a URL that does not exist, and write down which requests print one pair of lines and which print two. - Add a
defer()call to a controller, make the controller callabort(403)after it, and note whether the deferred code ran. Then add->always()and run the request again.
Then work through the self-check, which asks about each step of the path.
Practice that adapts to you
Practise this lesson
15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: in which order do the register and boot methods of service providers run, and why does the order matter?
The kernel creates every provider listed in bootstrap/providers.php and calls register on each of them. Only when all of them have registered does it call boot on each of them, in the same list order. The split exists so that a provider’s boot can depend on any binding made by any provider, whatever order they are listed in. It also tells you what each method is for. register should only bind things into the container, because a service from a later provider may not exist yet, and an attempt to use one fails depending on the order of the list. boot is where you use the container: you define gates, listen for events, add macros and register view composers there.
Three longer questions come from this path:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A teammate adds a SecurityHeaders middleware to the web group. The monitoring check on /up and the JSON routes under /api come back without the headers. Explain why, and say where you would register it instead.
-
Mid to senior
Each page request writes two audit lines, and each JSON request writes one. The audit middleware is registered once in the code you wrote. Find the cause, fix it, and describe the check that keeps it fixed.
Flashcards · 5 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Where does the whole path of a Laravel web request begin?
In public/index.php, which builds the application from bootstrap/app.php and hands the request to the HTTP kernel. Every request starts in that one place.
-
Which middleware layers does a new Laravel 13 project put around its routes?
Eight global middleware that wrap every request, a web group of six for browser routes and an api group of one. The /up health route and URLs that match no route get only the global layer.
-
In which order do service providers' register and boot methods run?
The kernel calls register on every provider in bootstrap/providers.php first, and only then boot on each, in the same order. So register should only bind services, and boot can use any binding of any provider.
-
In what order do nested middleware run on the way in and on the way out?
Like nested function calls: the first layer in is the last layer out. A middleware that returns a response without calling $next stops every deeper layer, while the outer layers still run their code after $next.
-
What do terminate and defer() have in common, and when is neither enough?
Both run work after the visitor has the response, in the same PHP process. A deferred function is skipped on a 4xx or 5xx response unless it chains always(), and neither retries, so work that must not be lost belongs in a queued job.
Key takeaways
- Every request enters through
public/index.php, which builds the application frombootstrap/app.phpand hands the request to the HTTP kernel, so there is one place where the whole path begins. - A new Laravel 13.34 project has eight global middleware that wrap every request, a six-middleware
webgroup for browser routes and a one-middlewareapigroup, and/upand unmatched URLs get only the global layer. - Every provider’s
registermethod runs before any provider’sbootmethod, soregisteronly binds services andbootis where you use them. - Middleware nests like function calls, so the first layer in is the last layer out, and a middleware that returns without calling
$nextstops everything deeper while the outer layers still run their after code. terminateanddefer()run after the response has been sent, a deferred function is skipped on a 4xx or 5xx response unless it chainsalways(), and neither retries, so work that must not be lost goes to a queue.
Terms in this lesson
- deferred function
- A closure passed to defer(). It runs after the response has been sent, and by default only when the response status is below 400.
- global middleware
- A middleware in the HTTP kernel's own list. It wraps every request, including one that matches no route.
- HTTP kernel
- The class that runs a web request from start to finish: it boots the framework, sends the request through the middleware and the router, and returns a response.
- middleware
- A class that sits between the web server and the controller. It can inspect or change a request, stop it with its own response, or pass it inward and then change the response on the way out.
- middleware group
- A named list of middleware, such as web or api, that the router attaches to every route loaded into the group.
- request lifecycle
- The path one request takes through an application: the front controller, the kernel, the middleware, the router and the controller, then the response and the work that runs after it.
- service container
- The object that builds and stores the classes an application needs. Laravel's application object is the container, and providers put their bindings into it.
- service provider
- A class that sets one part of the application up. Its register method binds services into the container, and its boot method uses them once every provider has registered.
- terminable middleware
- A middleware with a terminate method, which the kernel calls once the response has been sent, so slow work does not make the visitor wait.
References
- Request lifecycle (Laravel (laravel.com))
- Middleware (Laravel (laravel.com))
- Service providers (Laravel (laravel.com))
- Helpers, deferred functions (Laravel (laravel.com))
- Queues (Laravel (laravel.com))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress