Week 14: Middleware, Form Requests & File Uploads

The auth middleware from last week is one of many pieces intercepting every request before it reaches a controller. This week covers writing your own middleware, moving authorization logic into Form Requests properly, and Laravel's Storage facade — the framework-native replacement for Week 5's hand-written upload handling.

Module 13 of 28 Week 14 of 32 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Write & register custom middleware for cross-cutting request logic
  • Use a Form Request's authorize() method for per-request authorization
  • Store & serve uploaded files with the Storage facade

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:

the request lifecycle, simplified
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:

terminal
php artisan make:middleware EnsureUserIsAdmin
app/Http/Middleware/EnsureUserIsAdmin.php
<?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
    }
}
bootstrap/app.php — registering it (Laravel 11)
<?php

use App\Http\Middleware\EnsureUserIsAdmin;

->withMiddleware(function ($middleware) {
    $middleware->alias(['admin' => EnsureUserIsAdmin::class]);
})
routes/web.php — using it
<?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:

app/Http/Requests/UpdateTaskRequest.php
<?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:

TaskController.php — attachment upload
<?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!');
}
config/filesystems.php — disks, at a glance
'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:

terminal
php artisan storage:link
resources/views/tasks/show.blade.php — displaying an attachment
@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:

a controller-gated download
<?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

Hands-on

Add file attachments and a rate-limit middleware

Extend the task tracker with private file attachments and a custom middleware.

Requirements:

  1. Add an attachment_path nullable column to tasks via a new migration.
  2. Update the create-task form to accept an optional file upload, stored on the private (non-public) disk.
  3. Add a tasks.download route and controller method that calls Storage::download(), gated by $this->authorize('view', $task) via a new view method on TaskPolicy.
  4. Update UpdateTaskRequest's authorize() to use the Policy instead of returning true.
  5. Write a custom LogSlowRequests middleware that records the request duration and writes to the log if it exceeds 500ms, registered globally in bootstrap/app.php.
Hint

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?

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?

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?

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?

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.