Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Building Web Apps with Laravel  Module 3 – Requests, routing and middleware

Middleware and rate limiting

Write Laravel 13 middleware that runs before and after your code, attach it by alias, group or attribute, limit requests per key, and add safe response headers.

  • Intermediate
  • 10 minutes
  • Examples run with PHP 8.5.11, Composer 2.10.3, Laravel 13.34.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 10 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

What you will learn

  • Write a middleware that refuses a request before the action runs and changes the response after it
  • Add a middleware globally, to a group, to a route and with the Middleware attribute, and choose the right place
  • Define a named rate limiter and read the status and headers of a request it refuses
  • Choose a limiter key that does not lock out every user behind one shared address
  • Add security headers with a middleware and prove them with a test that fails without it

Before you start

What you’ll learn · Before you start

What you will learn

  • Write a middleware that refuses a request before the action runs and changes the response after it
  • Add a middleware globally, to a group, to a route and with the Middleware attribute, and choose the right place
  • Define a named rate limiter and read the status and headers of a request it refuses
  • Choose a limiter key that does not lock out every user behind one shared address
  • Add security headers with a middleware and prove them with a test that fails without it

Before you start

On this page

Pathshala’s sign-in page sends a one-time code by SMS, and every SMS costs money. A limit of 3 codes a minute for each phone number is generous for a parent and cheap for the school, but the same limit of 3 for each IP address would stop an entire school wifi network after one family had logged in. Both rules are middleware, and the difference between them is a single word in the code, the key.

This lesson writes a middleware with a before step and an after step, shows the four places it can be attached, runs a rate limiter against seven requests, and ends by adding the response headers that Laravel leaves out. Every example runs on Laravel 13.34 with the clock frozen, so the numbers you read are the numbers it printed.

A middleware can stop a request or change its response

The first lesson of this module showed middleware as layers around the router. A middleware is a class with one method, handle, which receives the request and a closure named $next. Anything the method does before $next($request) happens on the way in, and anything after happens on the way out. Returning a response without calling $next stops the request. Here is a middleware for Pathshala’s office desk:

app/Http/Middleware/EnsureCentreIsOpen.php PHP · mw-probe/app/Http/Middleware/EnsureCentreIsOpen.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

// Before: refuses a request outside opening hours. After: stamps the response that comes back.
class EnsureCentreIsOpen
{
    // The two arguments come from the text after the colon: 'centre.open:9,17' gives $opens = '9', $closes = '17'.
    public function handle(Request $request, Closure $next, string $opens, string $closes): Response
    {
        $hour = now()->hour;

        if ($hour < (int) $opens || $hour >= (int) $closes) {
            // Returning here, without calling $next, stops the request: no controller runs.
            return response("The centre is open from {$opens}:00 to {$closes}:00.", 403);
        }

        $response = $next($request);
        $response->headers->set('X-Open-Hours', "{$opens}-{$closes}");

        return $response;
    }
}

It takes two extra arguments, $opens and $closes. They arrive from the text after a colon in the route, so centre.open:9,17 calls handle($request, $next, '9', '17'), always as strings. Outside those hours it returns a 403 itself. Inside them it calls $next, takes the response that came back, and sets a header on it before returning it. Step through two requests, one at 08:30 and one at 10:30:

Interactive · message by message Refused before the action, stamped after it Step through two requests to /office, one before the centre opens and one after.
One middleware, two outcomes, step 7 of 7. Messages so far: Browser to centre.open: GET /office; centre.open to Browser: 403, opens at 9; Browser to centre.open: GET /office; centre.open to Action: $next($request); Action to centre.open: response; centre.open to Browser: 200 + header.Browser/officecentre.openadds headerActionoffice deskGET /office403, opens at 9GET /office$next($request)response200 + header

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. A request at 08:30. The browser asks for /office. The route carries centre.open:9,17, so the request reaches the middleware first, with 9 and 17 as its two extra arguments.
  2. The hour is outside the range. The middleware reads the clock, finds hour 8, and returns a 403 response itself. It never calls $next, so the route action is not reached.
  3. The refusal goes back. The 403 travels back to the browser with the message about opening hours. The action did no work.
  4. A request at 10:30. Two hours later the same request arrives. The middleware finds hour 10, inside the range, and decides to let it through.
  5. The action runs. The middleware calls $next($request). That runs the route action, which returns the text office desk.
  6. A header is added on the way out. The response is back in the middleware, which now sets the X-Open-Hours header on it and returns it. Work after $next sees the finished response.
  7. The browser gets 200. The browser receives the page with the header 9-17.

The route, with its alias, is one line of routes/web.php, and the alias is one line of bootstrap/app.php, where $middleware->alias(['centre.open' => EnsureCentreIsOpen::class]) gives the class its short name. middleware alias: A short name for a middleware class, registered in bootstrap/app.php, so that a route can write ->middleware('throttle:60,1') with the class's arguments after a colon. exist so that a route reads like a sentence and carries its arguments with it. The framework registers its own the same way: auth, throttle, signed and can are aliases.

Key idea

Work before $next sees the request and can refuse it. Work after $next sees the response and can change it. When a middleware returns a response itself and skips $next, nothing deeper runs.

Four places to attach a middleware, and what each one misses

Where you attach a middleware decides which requests meet it. The same alias runs the same code in all of these places:

routes/web.php, two ways to attach one alias PHP · mw-probe/routes/web.php
<?php

use App\Http\Controllers\HallController;
use Illuminate\Support\Facades\Route;

// Middleware on a route, by alias with arguments.
Route::get('/office', fn () => 'office desk')->middleware('centre.open:9,17');

// Middleware on a controller, by attribute.
Route::get('/hall', [HallController::class, 'show']);
Route::get('/hall/notice', [HallController::class, 'notice']);

/office carries the alias in its route. /hall gets it from a Middleware attribute on the controller, #[Middleware('centre.open:9,17', except: ['notice'])], which only and except narrow to chosen actions. Ask the router what it finally holds, and run the three paths at both hours:

Three paths at 08:30 and at 10:30, then route:list -v Python · hours_session.py
from session import run

# The same three paths at 08:30 and at 10:30. The route and the controller use the same alias.
run(
    """
php ask.php 8 /office /hall /hall/notice
php ask.php 10 /office /hall /hall/notice
php artisan route:list --except-vendor -v
""",
    app=True,
    overlay=("mw-probe",),
)

Output

$ php ask.php 8 /office /hall /hall/notice
8:30  GET /office           403  The centre is open from 9:00 to 17:00.
8:30  GET /hall             403  The centre is open from 9:00 to 17:00.
8:30  GET /hall/notice      200  the notice board is always on
$ php ask.php 10 /office /hall /hall/notice
10:30  GET /office          200  office desk  [X-Open-Hours: 9-17]
10:30  GET /hall            200  exam hall is ready  [X-Open-Hours: 9-17]
10:30  GET /hall/notice     200  the notice board is always on
$ php artisan route:list --except-vendor -v
  GET|HEAD       hall .................................... HallController@show
                 ⇂ web
                 ⇂ centre.open:9,17
  GET|HEAD       hall/notice ........................... HallController@notice
                 ⇂ web
  GET|HEAD       office ..................................... routes/web.php:7
                 ⇂ web
                 ⇂ centre.open:9,17

                                                            Showing [3] routes

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hours_session.py

At 08:30 the office and the hall answer 403 and the notice board answers 200, because the attribute excluded it. At 10:30 all three answer 200, and the two protected ones carry X-Open-Hours: 9-17. In the route list, -v shows centre.open:9,17 under the hall, with no mention of the attribute, because the router has already merged it. The list also shows web under each route, the group every route of routes/web.php belongs to.

The other places are the global list, with $middleware->append(...) or prepend(...), and a group, with $middleware->web(append: [...]) or appendToGroup. Their differences decide where each one belongs:

Interactive · answer and compare Choosing where to attach a middleware Answer the two questions. The table marks the places that fit, and gives the reason under each one that does not.
Must it also run on a URL that no route handles, such as an error page?
Does it belong to every route, or only to some of them?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Four places to attach a middleware against four things that differ between them
Place Requests it covers A URL no route handles Taking it off one route Arguments
Global How it is written: $middleware->append(Class::class) in bootstrap/app.php Good every request the application receives Good runs, so a 404 is covered too Poor withoutMiddleware cannot remove a global one Mixed one class, with arguments only through a text after a colon
A group How it is written: $middleware->web(append: [...]) or appendToGroup Mixed only routes loaded into the group, such as web Poor does not run, because no route was chosen Good withoutMiddleware on a route or a route group Mixed the same text after a colon
One route or route group How it is written: ->middleware('alias:args') in a routes file Good exactly the routes you name, or a group of them Poor does not run, because no route was chosen Good withoutMiddleware on a route inside a group Good alias:9,17 gives the arguments in place
Controller attribute How it is written: #[Middleware('alias')] on a class or method Good the actions of one controller, with only and except Poor does not run, because no route was chosen Mixed a WithoutMiddleware attribute or except on the class Good alias:9,17 works the same way
How the answers decide
  • “Must it also run on a URL that no route handles, such as an error page?” Yes, every response needs it: rules out A group, One route or route group and Controller attribute. A route is chosen after the global layer, so only a global middleware sees a request that matches no route.
  • “Does it belong to every route, or only to some of them?” Some routes: rules out Global. A global middleware cannot be switched off for one route, so a rule for some routes belongs in a group, a route or a controller.
  • “Does it belong to every route, or only to some of them?” Every route and “Must it also run on a URL that no route handles, such as an error page?” No, only matched routes: adds a caveat to A group. A group covers only the routes loaded into it, so an API route in another group is missed.

One more control exists for the rare case where the order of two middleware matters and you do not control it. A middleware priority: A fixed relative order for chosen middleware classes, set with the priority method, used when two of them must run in a given order whatever order a route lists them in. list, set with $middleware->priority([...]), fixes the order of the classes it names. The URL defaults of the routing lesson are the usual reason, a middleware that sets defaults must run before SubstituteBindings.

Excluding a global middleware from one route

withoutMiddleware() removes route middleware only. A class in the global list cannot be removed from a single route, so a rule that should skip some routes must not start out global.

Checkpoint

Check: Before and after $next, and stopping a request · Where to attach a middleware

  1. Question 1 of 2 A middleware needs to add an X-Open-Hours header to every response it lets through. Where in handle does the header line go?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: After $next($request), on the response it returned

    Response work goes after $next. The middleware keeps the returned response, sets the header on it and returns the same object.

  2. Question 2 of 2 A rule must apply to every response, including the 404 for a URL that no route handles. Where does it go?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: In the global list, with $middleware->append(...)

    Route-level and controller-level middleware run only after a route has been chosen. Only the global list wraps a request that matches nothing.

A named limiter counts requests for each key

Rate limiting is a middleware too. The throttle alias runs ThrottleRequests, which counts the requests that share a key and refuses the ones over the limit. You define a limiter by name in the boot method of a service provider, with the RateLimiter facade, and you attach it by that name. The probe project defines one for the fee lookup page:

app/Providers/AppServiceProvider.php PHP · throttle-probe/app/Providers/AppServiceProvider.php
<?php

namespace App\Providers;

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        //
    }

    public function boot(): void
    {
        // A named limiter: three requests a minute for each IP address.
        RateLimiter::for('fee-lookup', fn (Request $request) => Limit::perMinute(3)->by($request->ip()));
    }
}

Limit::perMinute(3)->by($request->ip()) says three requests in a rate limiter: A named rule, defined with RateLimiter::for, that allows a number of requests in a time window for each key and makes the throttle middleware answer 429 after that. window of a minute for each rate-limit key: The value a limiter counts requests under, such as an IP address, a user id or a phone number. Requests with the same key share one counter., and here the key is the caller’s address. The route says ->middleware('throttle:fee-lookup'). Send four requests from one address, one from another, one from a JSON client, and one after the minute has passed. The harness freezes the clock so that the numbers repeat:

Four requests, one from another address, then a minute later Python · throttle_session.py
from session import run

# Four requests from one address, one from another, a JSON client, and one request after the minute has passed.
run(
    """
export APP_DEBUG=false
php send_requests.php
""",
    app=True,
    overlay=("throttle-probe",),
)

Output

$ export APP_DEBUG=false
$ php send_requests.php
203.0.113.9, request 1    200  remaining 2
203.0.113.9, request 2    200  remaining 1
203.0.113.9, request 3    200  remaining 0
203.0.113.9, request 4    429  remaining 0  retry-after 60
198.51.100.4, request 1   200  remaining 2
203.0.113.9, as JSON      429  remaining 0  retry-after 60  JSON keys: message  message: Too Many Attempts.
203.0.113.9, 61 s later   200  remaining 2

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 throttle_session.py

The first three requests answer 200, with X-RateLimit-Remaining falling from 2 to 0. The fourth answers 429 with Retry-After: 60, which tells a client how many seconds to wait. The other address starts a fresh count, because its key is different. The request 61 seconds later answers 200 again with remaining 2, since the old counter has expired.

Interactive · stage by stage The decision for each request Step through the decision the throttle middleware makes, using the limiter of three a minute.
What throttle decides for each request, step 6 of 6. Showing Build the key, Read the counter, Already at 3?, Count it and pass (remaining 2, 1, 0), Stop with 429 (429, retry 60 s). In focus: Read the counter.window endsBuild the keylimiter name + by() valueRead the counterhits in this minuteAlready at 3?before this hitCount it and passremaining 2, 1, 0Stop with 429429, retry 60 s

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The key is built. The limiter function returns Limit::perMinute(3)->by($request->ip()). The key combines the limiter name and the address, so each address has a counter of its own.
  2. The counter is read. The middleware reads how many requests this key has made in the current minute. For the first request of 203.0.113.9 the answer is zero.
  3. The count is compared. Zero has not reached the limit of three, so the request is allowed. The framework compares the counter with the limit before it counts this hit.
  4. The first three pass. The middleware adds one to the counter and calls $next. The response carries X-RateLimit-Remaining of 2, then 1, then 0.
  5. The fourth is stopped. By the fourth request the counter is three, which is the limit. The middleware throws, and Laravel answers 429 with a Retry-After header of 60 seconds. The controller never runs.
  6. The window moves on. After the minute passes the counter expires. In the run, a request 61 seconds later answered 200 with remaining 2, as the first one had.

A JSON client gets a JSON body, and what the body carries depends on debug mode. In the run above, with debug off, the body has the single key message. With debug on, it carries four more:

The same requests with APP_DEBUG=true Python · throttle_debug_session.py
from session import run

# The same requests with debug mode on: look at what the 429 answer for a JSON client carries.
run(
    """
export APP_DEBUG=true
php send_requests.php
""",
    app=True,
    overlay=("throttle-probe",),
)

Output

$ export APP_DEBUG=true
$ php send_requests.php
203.0.113.9, request 1    200  remaining 2
203.0.113.9, request 2    200  remaining 1
203.0.113.9, request 3    200  remaining 0
203.0.113.9, request 4    429  remaining 0  retry-after 60
198.51.100.4, request 1   200  remaining 2
203.0.113.9, as JSON      429  remaining 0  retry-after 60  JSON keys: message, exception, file, line, trace  message: Too Many Attempts.
203.0.113.9, 61 s later   200  remaining 2

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 throttle_debug_session.py

The JSON line now lists the keys message, exception, file, line, trace. A 429 body that contains a stack trace tells an attacker which file and line produced it. Keep debug mode off on any server that other people can reach.

Checkpoint

Check: Named limiters and the 429 answer

  1. Question 1 of 2 A limiter allows three requests a minute for each address. What happened to the fourth request from the same address in the lesson's run?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The throttle middleware answered 429 with Retry-After, and the controller did not run

    ThrottleRequests reads the counter before it calls $next. At three hits in the window it throws, Laravel answers 429 and sets Retry-After.

  2. Question 2 of 2 A limiter allows 3 requests a minute for each address. The first address makes 4 requests, then a second address makes 1. How many of these 5 requests answer 200?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 4 requests

    The first address gets 3 answers of 200 and one 429. The second address has a counter of its own and gets 200. That is 4 answers of 200, as the lesson's run shows.

The key decides who gets locked out

Limiting by address is the easy choice, and it fails exactly where a school lives. Many families share one address, behind the school wifi or a mobile carrier’s gateway. Pathshala’s OTP route should limit each phone number tightly, so one number cannot flood a parent with SMS, and also limit each address more loosely, so a script cannot walk through thousands of numbers. A limiter function may return a list of limits, and a request must pass all of them:

Two limiters for the OTP route PHP · otp-probe/app/Providers/AppServiceProvider.php
<?php

namespace App\Providers;

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        //
    }

    public function boot(): void
    {
        // Two limits at once: 3 a minute for each phone number from each address, and 20 a minute for each address.
        RateLimiter::for('otp', fn (Request $request) => [
            Limit::perMinute(3)->by($request->input('phone').'|'.$request->ip()),
            Limit::perMinute(20)->by($request->ip()),
        ]);

        // The same address limit, written the careless way: one bucket for everyone who shares the address.
        RateLimiter::for('otp-by-ip', fn (Request $request) => Limit::perMinute(3)->by($request->ip()));
    }
}

The otp limiter keys three a minute on the phone number joined to the address, and twenty a minute on the address alone. The otp-by-ip limiter is the careless version, three a minute for the address. Three families sit behind one address and post to both routes:

Three families on one address, against two limiters Python · otp_session.py
from session import run

# Three families behind one address, against a limiter keyed by phone number and one keyed by address.
run(
    """
php otp_requests.php
""",
    app=True,
    overlay=("otp-probe",),
)

Output

$ php otp_requests.php
limiter keyed by phone and address, three families on one address
  98000 00001: 200 200 200 429
  98000 00002: 200 200 200 429
  98000 00003: 200 200 200 429
limiter keyed by address only, the same three families
  98000 00001: 200 200
  98000 00002: 200 429
  98000 00003: 429 429

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 otp_session.py

With the key of phone and address, each family gets its own three codes and its own fourth refusal, and the address limit of twenty is never reached. With the address alone, the first family uses both of its attempts, the second gets one and a refusal, and the third is refused at once, although it has not asked for a code before.

Behind a load balancer there is a second trap, in which the address is always the balancer’s, and every user shares it whatever the key was meant to be. The requests lesson shows the setting that makes $request->ip() return the visitor’s address. For routes where a user is signed in, the user’s id is the better key, as in the documentation’s $request->user()?->id ?: $request->ip().

A key made of input is a key the caller chooses

When the key includes a value from the request, such as a phone number, a caller can change it to start a new count. That is why the phone limit is paired with an address limit, which a caller cannot change from one machine.

Checkpoint

Check: Choosing the limiter key

  1. Question 1 of 2 Pathshala limits the OTP route to 3 a minute and keys it by IP address only. Fifty families log in from the school wifi at 9 a.m. What happens?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Almost all of them get 429, because they share one counter

    A key made of the address alone groups everyone who shares it. The fix is a key that adds the phone number, with a looser address limit beside it.

  2. Question 2 of 2 A limiter function returns [Limit::perMinute(3)->by($phone.'|'.$ip), Limit::perMinute(20)->by($ip)]. When does a request get through?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Only when it is within both limits

    The array holds limits that all apply. A request that is over the phone limit or over the address limit is stopped.

A middleware adds the security headers Laravel leaves out

A default Laravel response carries almost none of the headers that tell a browser how to treat a page. The OWASP HTTP Headers Cheat Sheet lists them. Pathshala’s first set is small: X-Content-Type-Options: nosniff so the browser does not guess a file type, Referrer-Policy: strict-origin-when-cross-origin so full URLs do not leak to other sites, X-Frame-Options: SAMEORIGIN so other sites cannot put the page in a frame, and Cache-Control: no-store, private for pages that show a child’s data. Each is a security header: A response header that tells the browser how to treat a page, for example X-Content-Type-Options, Referrer-Policy and X-Frame-Options. Laravel sets almost none by default.. The cheat sheet adds that a Content-Security-Policy frame-ancestors directive obsoletes X-Frame-Options for browsers that support it, so the older header is a cheap first step rather than the last word. Begin with the test, which states what every response must carry, whether it is a page, a missing URL or the health check:

tests/Feature/SecurityHeadersTest.php PHP · headers-test/tests/Feature/SecurityHeadersTest.php
<?php

namespace Tests\Feature;

use Tests\TestCase;

class SecurityHeadersTest extends TestCase
{
    // The headers every response should carry, whatever the route: a page, a 404 and the health check.
    public function test_every_response_carries_the_safe_defaults(): void
    {
        foreach (['/', '/no-such-page', '/up'] as $path) {
            $this->get($path)
                ->assertHeader('X-Content-Type-Options', 'nosniff')
                ->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
                ->assertHeader('X-Frame-Options', 'SAMEORIGIN');
        }
    }

    public function test_pages_with_personal_data_are_not_stored(): void
    {
        $this->get('/')->assertHeader('Cache-Control', 'no-store, private');
    }
}

Run it on a new project:

php artisan test, before the middleware Python · headers_before_session.py
from session import run

# The test describes the headers; a new project sends none of them, so it fails.
run(
    """
!php artisan test --filter=SecurityHeadersTest
""",
    app=True,
    overlay=("headers-test",),
)

Output

$ php artisan test --filter=SecurityHeadersTest
   FAIL  Tests\Feature\SecurityHeadersTest
  ⨯ every response carries the safe defaults                             …s
  ⨯ pages with personal data are not stored                              …s
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\SecurityHeadersTest > every response carries the sa…
  Header [X-Content-Type-Options] not present on response.
Failed asserting that false is true.

  at tests/Feature/SecurityHeadersTest.php:14
     10▕     public function test_every_response_carries_the_safe_defaults(): void
     11▕     {
     12▕         foreach (['/', '/no-such-page', '/up'] as $path) {
     13▕             $this->get($path)
  ➜  14▕                 ->assertHeader('X-Content-Type-Options', 'nosniff')
     15▕                 ->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
     16▕                 ->assertHeader('X-Frame-Options', 'SAMEORIGIN');
     17▕         }
     18▕     }

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\SecurityHeadersTest > pages with personal data are…
  Header [Cache-Control] was found, but value [no-cache, private] does not match [no-store, private].
Failed asserting that two strings are equal.
  -'no-store, private'
  +'no-cache, private'


  at tests/Feature/SecurityHeadersTest.php:22
     18▕     }
     19▕
     20▕     public function test_pages_with_personal_data_are_not_stored(): void
     21▕     {
  ➜  22▕         $this->get('/')->assertHeader('Cache-Control', 'no-store, private');
     23▕     }
     24▕ }
     25▕


  Tests:    2 failed (3 assertions)
  Duration: …s
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 headers_before_session.py

Both tests fail. The first says Header [X-Content-Type-Options] not present on response, which confirms that nothing sets it. The second shows the one header Laravel does send, Cache-Control: no-cache, private, and that it differs from what the test wants. A test that fails on the weak default is the proof that it checks the right thing. Now add the middleware, registered globally so that a 404 and the health check are covered:

app/Http/Middleware/SecurityHeaders.php PHP · headers-fix/app/Http/Middleware/SecurityHeaders.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

// Adds a small set of response headers to every response. They are written after $next, on the response it returned.
class SecurityHeaders
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        $response->headers->set('X-Content-Type-Options', 'nosniff');
        $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
        $response->headers->set('X-Frame-Options', 'SAMEORIGIN');
        // Laravel marks a page "no-cache, private". These pages show a child's name, so ask browsers not to keep them.
        $response->headers->set('Cache-Control', 'no-store, private');

        return $response;
    }
}

It sets the headers after $next, on the returned response, and the fix is one line in bootstrap/app.php: $middleware->append(SecurityHeaders::class). Run the same test again:

php artisan test, after the middleware Python · headers_after_session.py
from session import run

# The same test after the middleware is added and registered globally.
run(
    """
php artisan test --filter=SecurityHeadersTest
""",
    app=True,
    overlay=("headers-test", "headers-fix"),
)

Output

$ php artisan test --filter=SecurityHeadersTest
   PASS  Tests\Feature\SecurityHeadersTest
  ✓ every response carries the safe defaults                             …s
  ✓ pages with personal data are not stored                              …s

  Tests:    2 passed (20 assertions)
  Duration: …s

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 headers_after_session.py

Both pass, with 20 assertions behind them. One part of this middleware is blunt: no-store goes on every response, including public pages that could be cached. Pathshala would move that single line into a separate middleware attached to the routes that show a child’s data, and leave the three others global.

Checkpoint

Check: Security headers from a middleware

  1. One question The lesson ran its header test before the middleware existed and it failed. Why is that useful?

    Choose one answer.

    Show the answer: One question

    Answer: It shows that the test can detect the missing headers, so a pass later means something

    Writing the check first and seeing it fail on the default response shows that the check can fail. When it passes after the change, the change is what made it pass.

Practise

Add the pieces to your own Pathshala project, and check each one with a request:

  1. Write a middleware that reads the X-Request-Source header and returns 400 when it is missing, and register it with an alias. Attach it to one route, then to a controller with #[Middleware], and confirm both with php artisan route:list -v.
  2. Define a limiter named receipts that allows 5 requests a minute for each signed-in user and 2 for each guest address. Write a feature test that sends six requests as one user and asserts that the sixth answers 429, then travels a minute forward and asserts 200.
  3. Add the security headers middleware and the test from this lesson, then move Cache-Control into its own middleware and attach it only to the routes that show student data.

Then work through the self-check.

Practice that adapts to you

Practise this lesson

16 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 16 What does a middleware do to stop a request from reaching the controller?

    Choose one answer.

    Show the answer: Question 1 of 16

    Answer: It returns a response without calling $next

    The layers below the middleware run when $next is called. A middleware that returns a response of its own ends the request at its layer.

  2. Question 2 of 16 A route says ->middleware('centre.open:9,17'). What does handle receive after $request and $next?

    Choose one answer.

    Show the answer: Question 2 of 16

    Answer: The strings '9' and '17'

    A text after the colon becomes the extra arguments of handle, split at commas, always as strings. Cast them when you compare numbers.

  3. Question 3 of 16 Two middleware, A listed first and B second, both print a line after their $next calls. A route action prints in the middle. Which order do the three after-the-fact prints come in?

    Choose one answer.

    Show the answer: Question 3 of 16

    Answer: Action, B after, A after

    Layers close in the opposite order they opened. The last to go in, B, is the first to come out.

  4. Question 4 of 16 What does $middleware->alias(['centre.open' => EnsureCentreIsOpen::class]) do?

    Choose one answer.

    Show the answer: Question 4 of 16

    Answer: It gives the class a short name that routes can use

    An alias maps a short name to a class. The middleware runs only on the routes that name it.

  5. Question 5 of 16 A controller has

    Choose one answer.

    Show the answer: Question 5 of 16

    Answer: Every action except notice

    A Middleware attribute on a class covers all its actions, and only or except narrows that list.

  6. Question 6 of 16 A global middleware must skip one webhook route. What do you do?

    Choose one answer.

    Show the answer: Question 6 of 16

    Answer: Move the middleware out of the global list into a group that the webhook route is not part of

    Global middleware cannot be removed per route. A rule that has exceptions belongs in a group or on routes.

  7. Question 7 of 16 A middleware that sets URL defaults has to run before SubstituteBindings, but you do not control the order in which routes list their middleware. Which setting helps?

    Choose one answer.

    Show the answer: Question 7 of 16

    Answer: $middleware->priority([...]) with the classes in the order you need

    The priority method sets the order for chosen middleware independently of how they were attached.

  8. Question 8 of 16 In the lesson's run, the fourth request answered 429 at 10:00:00. What did Retry-After say, in seconds?

    Type a number.

    Show the answer: Question 8 of 16

    Answer: 60 seconds

    The window is one minute and the counter began with the first request at the same frozen second, so the client is told to wait 60 seconds.

  9. Question 9 of 16 Where is a named limiter such as fee-lookup defined?

    Choose one answer.

    Show the answer: Question 9 of 16

    Answer: In the boot method of a service provider, with RateLimiter::for

    RateLimiter::for(name, closure) registers the limiter when the application boots, and throttle:name uses it.

  10. Question 10 of 16 The 429 body for a JSON client contained exception, file, line and trace. What should you do?

    Choose one answer.

    Show the answer: Question 10 of 16

    Answer: Turn APP_DEBUG off on that server

    Debug mode puts the exception details in the body. Production servers keep it off, which leaves only the message.

  11. Question 11 of 16 A route is for signed-in parents only. Which key is best for its limiter?

    Choose one answer.

    Show the answer: Question 11 of 16

    Answer: The user's id

    A signed-in user has an identity, and using it as the key limits that person alone.

  12. Question 12 of 16 A limiter keys on the phone number from the request body only. What can an attacker do?

    Choose one answer.

    Show the answer: Question 12 of 16

    Answer: Send each request with a different number and never be counted twice

    When a caller picks the key, a new value starts a new count. Pair it with a limit the caller cannot change from one machine, such as the address.

  13. Question 13 of 16 Behind a load balancer, every request reaches Pathshala from the balancer's address, and the limiter is keyed by $request->ip(). What is the effect?

    Choose one answer.

    Show the answer: Question 13 of 16

    Answer: All visitors share one counter, so a few requests lock out everybody

    Without trusted proxies, $request->ip() returns the proxy's address for every visitor, so one key covers all.

  14. Question 14 of 16 What does X-Content-Type-Options set to nosniff ask the browser to do?

    Choose one answer.

    Show the answer: Question 14 of 16

    Answer: Use the declared content type and not guess one from the file's bytes

    nosniff stops the browser from guessing a content type, which closes a way for a file to be treated as a script.

  15. Question 15 of 16 Why is the SecurityHeaders middleware registered globally and not in the web group?

    Choose one answer.

    Show the answer: Question 15 of 16

    Answer: So that a 404, the health check and API responses carry the headers too

    The test asks for the headers on a page, a missing URL and the health check, and only the global list covers all three.

  16. Question 16 of 16 The lesson's middleware sets Cache-Control to no-store on every response. What does it say should be improved?

    Choose one answer.

    Show the answer: Question 16 of 16

    Answer: Attach no-store only to routes that show a child's data and leave public pages cacheable

    The blunt part of the middleware is the cache header. Moving it into a separate middleware on the sensitive routes keeps the other three global.

Interview questions

Warm-up: what is the difference between work done before $next($request) and work done after it in a middleware?

Work before $next happens on the way in. The middleware sees only the request, so it can read or change the request, or refuse it by returning a response of its own, in which case $next is never called and nothing deeper runs. Work after $next happens on the way out. The middleware now holds the response that the controller or a deeper layer returned, so it can add headers, change the status or log the result. Use the first for checks such as authentication, hours and rate limits, and the second for anything that decorates a response, such as security headers. A header set before $next is lost, because no response exists yet, and a check made after it is too late to stop the controller from running.

Three longer questions come from this lesson:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    On the morning of a mock test, parents report that the sign-in code never arrives, and the log shows a stream of 429 answers from the OTP route. Everyone was on the school's wifi. Find the cause in the limiter, redesign it, and say how you would test the new design.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    A security scan reports that the home page has an X-Frame-Options header but the 404 page and the health check do not. The header comes from a middleware your team wrote. Explain how that can happen and how you would fix it and keep it fixed.

    Model answer, Pro. First step and follow-ups

  3. Mid to senior

    A maintenance-hours middleware was added to the global list and now it blocks a payment webhook that must always be reachable. Someone suggests withoutMiddleware on the webhook route. Say whether that works, and propose a design that does.

    Model answer, Pro. First step and follow-ups

Flashcards · 5 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What can a middleware do before calling $next($request), and what after?

    Before $next it sees only the request, so it can check, change or refuse it by returning its own response, and then nothing deeper runs. After $next it holds the response, so it can add headers, change the status or log the result.

  2. Where can a middleware be attached, and which place also sees requests no route handles?

    Globally, to a middleware group, to a route or route group by alias, or to a controller with the Middleware attribute. Only a global middleware also runs for a URL that matches no route.

  3. What does a limiter defined with RateLimiter::for do once a key passes its limit?

    It counts the requests of each key and lets them through up to the limit. Past it, every request gets status 429, and its Retry-After header says how many seconds to wait.

  4. Why is an IP address alone a poor key for limiting OTP requests at a coaching centre?

    Many families can share one address, so a limit on the address alone locks all of them out together. A key of phone number and address, with a looser limit on the address, protects the SMS budget without that cost.

  5. Which security headers does a new Laravel app send, and how do you add them?

    Almost none by default. A global middleware that sets them after $next, plus a test that fails when one is missing, makes the headers a checked part of the application.

Key takeaways

  • A middleware sees the request before $next and the response after it, and returning a response without calling $next stops the request at that layer.
  • A middleware is attached globally, to a group, to a route or a route group by alias, or to a controller with the Middleware attribute, and only a global middleware also sees a request that no route handles.
  • A named limiter defined with RateLimiter::for counts requests for each key, lets the requests up to the limit through, and answers 429 with a Retry-After header after that.
  • The key decides who is locked out, so a limit keyed by address alone blocks every family behind one shared address, and a phone-and-address key plus a looser address limit protects the SMS budget without that cost.
  • Laravel sends almost no security headers by default, so a global middleware that sets them after $next and a test that fails without it make the headers a checked part of the application.

Terms in this lesson

middleware alias
A short name for a middleware class, registered in bootstrap/app.php, so that a route can write ->middleware('throttle:60,1') with the class's arguments after a colon.
middleware priority
A fixed relative order for chosen middleware classes, set with the priority method, used when two of them must run in a given order whatever order a route lists them in.
rate limiter
A named rule, defined with RateLimiter::for, that allows a number of requests in a time window for each key and makes the throttle middleware answer 429 after that.
rate-limit key
The value a limiter counts requests under, such as an IP address, a user id or a phone number. Requests with the same key share one counter.
security header
A response header that tells the browser how to treat a page, for example X-Content-Type-Options, Referrer-Policy and X-Frame-Options. Laravel sets almost none by default.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.