1. What Middleware Actually Is
Middleware sits between the incoming request and your controller,
able to inspect, modify or reject the request before it arrives — or modify the
response on the way back out. auth from last week is middleware; so is
CSRF protection, which Laravel applies to every web route by default:
Request
→ Middleware 1 (e.g. TrustProxies)
→ Middleware 2 (e.g. VerifyCsrfToken)
→ Middleware 3 (e.g. auth)
→ Controller
← Middleware 3 (can act on the way back out too)
← Middleware 2
← Middleware 1
Response
This is precisely the "structural separation of code from data" idea from Week 7, applied to request handling: cross-cutting concerns (auth checks, CSRF validation, logging) live in one reusable layer instead of being repeated inside every controller method.
2. Writing Custom Middleware
A middleware class has one handle() method: inspect the request,
decide whether to let it continue ($next($request)) or short-circuit
it:
php artisan make:middleware EnsureUserIsAdmin
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsAdmin
{
public function handle(Request $request, Closure $next): Response
{
if ($request->user()?->role !== 'admin') {
abort(403, 'Admins only.');
}
return $next($request); // continue to the next middleware / the controller
}
}
<?php
use App\Http\Middleware\EnsureUserIsAdmin;
->withMiddleware(function ($middleware) {
$middleware->alias(['admin' => EnsureUserIsAdmin::class]);
})
<?php
Route::middleware(['auth', 'admin'])->group(function () {
Route::get('/admin/users', AdminUserController::class);
});
Middleware stack in order: auth runs first (must be logged in at all),
then admin (must specifically be an admin) — each one independently
reusable across any route that needs it.
3. Form Request Authorization
Week 10 left authorize() returning true unconditionally.
Now that Policies exist, a Form Request's authorize() is the right
place to enforce per-request authorization before validation even runs:
<?php
namespace App\Http\Requests;
use App\Models\Task;
use Illuminate\Foundation\Http\FormRequest;
class UpdateTaskRequest extends FormRequest
{
public function authorize(): bool
{
$task = $this->route('task'); // route model binding still applies inside a Form Request
return $this->user()->can('update', $task);
}
public function rules(): array
{
return [
'title' => 'required|string|max:200',
'is_done' => 'boolean',
];
}
}
If authorize() returns false, Laravel throws a 403
automatically before rules() is even evaluated — authorization and
validation both happen before a single line of the controller method's body runs.
4. The Storage Facade
Week 10 briefly used $request->file(...)->store(...). The
Storage facade is the fuller API behind it — configurable across
local disk, S3, or any other supported driver, without changing your application
code:
<?php
use Illuminate\Support\Facades\Storage;
public function store(StoreTaskRequest $request)
{
$data = $request->validated();
if ($request->hasFile('attachment')) {
$data['attachment_path'] = $request->file('attachment')
->store('attachments', 'public'); // stores under storage/app/public/attachments/
}
$request->user()->tasks()->create($data);
return redirect()->route('tasks.index')->with('success', 'Task created!');
}
'disks' => [
'local' => ['driver' => 'local', 'root' => storage_path('app/private')],
'public' => ['driver' => 'local', 'root' => storage_path('app/public'), 'url' => env('APP_URL').'/storage'],
's3' => ['driver' => 's3', /* ... credentials from .env ... */],
],
Swapping 'public' for 's3' in the store()
call — with S3 credentials configured in .env — moves uploads to cloud
storage with no other code changes. That configurability is the actual point of
routing uploads through Storage instead of calling
move_uploaded_file() directly the way Week 5 did.
5. Serving Files Back
Files stored on the public disk need a symlink from
public/storage to storage/app/public before they're
reachable over HTTP — a one-time setup step:
php artisan storage:link
@if ($task->attachment_path)
<a href="{{ Storage::url($task->attachment_path) }}">View attachment</a>
@endif
For files that shouldn't be publicly reachable by URL guessing — user documents, private uploads — store on the default (non-public) disk instead, and stream them through a controller route that checks authorization first:
<?php
public function downloadAttachment(Task $task)
{
$this->authorize('view', $task); // same Policy mechanism from last week
return Storage::download($task->attachment_path);
}
6. Hands-on Exercise
Add file attachments and a rate-limit middleware
Extend the task tracker with private file attachments and a custom middleware.
Requirements:
- Add an
attachment_pathnullable column totasksvia a new migration. - Update the create-task form to accept an optional file upload, stored on the private (non-public) disk.
- Add a
tasks.downloadroute and controller method that callsStorage::download(), gated by$this->authorize('view', $task)via a newviewmethod onTaskPolicy. - Update
UpdateTaskRequest'sauthorize()to use the Policy instead of returningtrue. - Write a custom
LogSlowRequestsmiddleware that records the request duration and writes to the log if it exceeds 500ms, registered globally inbootstrap/app.php.
To time a request in LogSlowRequests, record microtime(true) before calling $next($request), and compare it against another microtime(true) call right after — the elapsed time on the way back out is exactly what middleware's "before and after" position in the request lifecycle is for.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What does calling $next($request) inside a middleware's handle() method actually do?
What does calling $next($request) inside a middleware's handle() method actually do?
It passes the request along to the next layer in the pipeline — the next middleware, or the controller if this is the last one — and returns whatever response that layer eventually produces. Skipping this call (returning a response directly instead, as EnsureUserIsAdmin effectively does via abort(403)) short-circuits the request right there; nothing further down the chain ever runs.
Q2
In Route::middleware(['auth', 'admin']), why does order matter?
In Route::middleware(['auth', 'admin']), why does order matter?
Middleware runs in the order listed. auth running first ensures a user object actually exists on the request before admin tries to check its role property — reversing the order would mean the admin check runs against a possibly-null user for unauthenticated requests, which is both a logic bug and a confusing error message compared to the correct 401-then-403 progression.
Q3
Why route file uploads through the Storage facade instead of calling move_uploaded_file() the way Week 5 did?
Why route file uploads through the Storage facade instead of calling move_uploaded_file() the way Week 5 did?
Storage abstracts over which underlying disk actually stores the file — local disk, S3, or another driver — behind one consistent API. Changing where files physically live (say, moving from local storage to S3 for production) means changing one config value, not rewriting every place in the application that handles an upload.
Q4
Why store a sensitive attachment on a private disk and serve it through a controller, rather than the public disk with a directly guessable URL?
Why store a sensitive attachment on a private disk and serve it through a controller, rather than the public disk with a directly guessable URL?
A file on the public disk is reachable by anyone who has (or guesses) its URL — there's no way to check whether the requester is actually authorized to view it, since a plain static file has no code running in front of it. Serving it through a controller route lets $this->authorize('view', $task) run first, exactly like every other Policy-gated action, before the file is ever streamed back.