Router sudah bisa mengarahkan request ke handler yang tepat, tetapi ada kebutuhan yang tidak milik satu handler pun: mencatat setiap request, memeriksa token login, atau menambahkan header keamanan pada setiap response. Menempelkan tugas-tugas tersebut ke dalam setiap handler akan menghasilkan kode yang tercopy-paste di mana-mana. Middleware adalah pola untuk mengatasi masalah tersebut: lapisan-lapisan kecil yang membungkus handler sehingga setiap request dan response melewati kode yang sama tanpa handler mengetahuinya. Pada chapter ini kita akan membangun pipeline middleware bergaya model bawang (onion model) dan membuktikan urutan eksekusinya dengan kode yang bisa dijalankan.

Masalah Cross-Cutting Concerns #

Tugas seperti autentikasi, logging, dan penambahan header disebut cross-cutting concerns karena memotong semua fitur aplikasi, bukan milik satu fitur saja. Bayangkan aplikasi dengan 20 handler: jika setiap handler memeriksa token sendiri, satu perubahan aturan login harus disalin ke 20 tempat. Pola middleware memindahkan tugas tersebut ke lapisan terpisah yang dipasang di depan handler. Dengan begitu, handler tetap fokus pada isi halaman, sementara lapisan pembungkus menangani hal-hal yang berlaku untuk semua request. Framework besar seperti Laravel dan Symfony memakai pola yang sama persis.

Kontrak Middleware dan Callable next #

Sebuah middleware pada dasarnya adalah callable dengan tugas ganda: memproses request sebelum diteruskan, dan memproses response sebelum dikembalikan. Kunci pola ini ada pada parameter $next — sebuah callable yang mewakili “sisa pipeline”. Middleware WAJIB memanggil $next agar request mencapai handler; jika tidak, perjalanan request berhenti di middleware tersebut. Kita tulis kontraknya sebagai interface:

php
<?php

declare(strict_types=1);

interface Middleware
{
    /**
     * Memproses request, lalu meneruskannya ke lapisan berikutnya
     * lewat callable $next, dan mengembalikan Response.
     */
    public function handle(Request $request, callable $next): Response;
}

Untuk fokus pada alur pipeline, kita pakai dua value object minimal — Request pembawa informasi masuk dan Response hasil akhir yang bisa diperkaya header:

php
<?php

declare(strict_types=1);

final class Request
{
    public function __construct(
        public readonly string $method,
        public readonly string $path,
        public readonly array $headers = [],
    ) {}

    public function header(string $name): string
    {
        return $this->headers[$name] ?? '';
    }
}

final class Response
{
    public function __construct(
        public readonly string $body,
        public readonly array $headers = [],
        public readonly int $status = 200,
    ) {}

    public function withHeader(string $name, string $value): static
    {
        return new self($this->body, [...$this->headers, $name => $value], $this->status);
    }
}

Standar industri untuk kontrak ini adalah PSR-15 dari PHP-FIG: interface MiddlewareInterface dengan method process() yang menerima ServerRequestInterface dan RequestHandlerInterface. Bentuknya berbeda, tetapi idenya identik — request diteruskan lewat handler dan Response mengalir kembali.

Model Bawang: Pipeline Berlapis #

Middleware tidak dipanggil berurutan seperti antrean biasa; middleware saling membungkus seperti lapisan bawang. Handler inti berada di tengah, middleware pertama berada di kulit terluar. Request menembus dari luar ke dalam, response mengalir dari dalam ke luar:

Diagram di atas menunjukkan perjalanan satu request: masuk lewat AuthMiddleware, lalu LoggingMiddleware, mencapai handler, lalu response menempuh jalur yang sama dalam arah sebaliknya. Kita terjemahkan model tersebut menjadi class Pipeline:

php
<?php

declare(strict_types=1);

final class Pipeline
{
    /** @var list<Middleware> */
    private array $layers = [];

    public function add(Middleware ...$layers): void
    {
        foreach ($layers as $layer) {
            $this->layers[] = $layer;
        }
    }

    public function run(Request $request, callable $core): Response
    {
        $next = $core;

        // Bungkus dari dalam ke luar: layer yang didaftarkan paling akhir
        // berada paling dekat dengan handler inti.
        foreach (array_reverse($this->layers) as $layer) {
            $next = static fn (Request $r): Response => $layer->handle($r, $next);
        }

        return $next($request);
    }
}

Mari kita bedah run() baris per baris:

  1. $next = $core; — pembungkus paling dalam adalah handler inti itu sendiri. Variabel $next akan terus diganti oleh loop dengan pembungkus yang lebih luar.
  2. foreach (array_reverse($this->layers) ...) — kita menyusun dari dalam ke luar, jadi daftar layer dibalik: layer terakhir yang didaftarkan dibungkus lebih dulu, dan layer pertama menjadi pembungkus terluar.
  3. $next = static fn (Request $r): Response => $layer->handle($r, $next); — setiap iterasi membuat closure baru yang membungkus $next lama. Closure ini, saat dipanggil, menyerahkan kendali ke $layer->handle() dan menganggap $next lama sebagai “lapisan berikutnya”.
  4. return $next($request); — memanggil pembungkus terluar memicu reaksi berantai hingga handler inti, lalu nilai baliknya mengalir kembali keluar.

Hasil akhirnya: satu callable bersarang yang meniru struktur bawang persis seperti diagram.

Membuktikan Urutan Eksekusi #

Teori model bawang mudah diklaim, jadi kita buktikan dengan middleware pelacak yang mencatat setiap kedatangan request dan keberangkatan response ke sebuah TraceLog:

php
<?php

declare(strict_types=1);

require __DIR__ . '/pipeline.php';

$log = new TraceLog();
$core = static fn (Request $r): Response => new Response("isi halaman {$r->path}");

$pipeline = new Pipeline();
$pipeline->add(new TraceMiddleware('Auth', $log));
$pipeline->add(new TraceMiddleware('Logging', $log));

$response = $pipeline->run(new Request('GET', '/dashboard'), $core);

echo $log, "\n";
echo "Body akhir: {$response->body}\n";

Output yang Dihasilkan:

text
Auth: request masuk
Logging: request masuk
Logging: response keluar
Auth: response keluar
Body akhir: isi halaman /dashboard

Baca output di atas dari atas ke bawah: Auth terdaftar lebih dulu sehingga menjadi kulit terluar — request melewati Auth dulu, baru Logging, baru handler inti. Setelah handler selesai, urutan terbalik: Logging memproses response lebih dulu, Auth terakhir. Perhatikan juga bahwa body handler utuh sampai ke pemanggil — middleware hanya menambah jejak, tidak mengubah isi.

Middleware Menambah Header Keamanan #

Sekarang kita lihat middleware yang benar-benar mengubah response. Contoh klasiknya adalah menambahkan header keamanan standar pada semua response:

php
<?php

declare(strict_types=1);

require __DIR__ . '/pipeline.php';

final class SecurityHeadersMiddleware implements Middleware
{
    public function handle(Request $request, callable $next): Response
    {
        $response = $next($request);

        return $response
            ->withHeader('X-Frame-Options', 'DENY')
            ->withHeader('X-Content-Type-Options', 'nosniff');
    }
}

$core = static fn (Request $r): Response => new Response('Profil Budi');

$pipeline = new Pipeline();
$pipeline->add(new SecurityHeadersMiddleware());
$response = $pipeline->run(new Request('GET', '/profil'), $core);

foreach ($response->headers as $name => $value) {
    echo "$name: $value\n";
}
echo $response->body, "\n";

Output yang Dihasilkan:

text
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Profil Budi

Perhatikan urutan kerjanya: middleware memanggil $next($request) lebih dulu untuk mendapatkan Response dari handler, baru memperkaya response tersebut dengan withHeader(). Pola “panggil dulu, ubah belakangan” adalah ciri middleware yang bekerja pada arah keluar (response). Handler tidak perlu tahu header apa pun — dari sudut pandang handler, header keamanan muncul gratis.

Short-Circuit: Menolak Request Sebelum Handler #

Kekuatan sesungguhnya middleware ada pada kemampuannya menghentikan perjalanan request — teknik yang disebut short-circuit atau early return. Middleware autentikasi adalah contoh terbaiknya: jika token tidak sah, $next tidak pernah dipanggil, dan response ditolak langsung:

php
<?php

declare(strict_types=1);

require __DIR__ . '/pipeline.php';

final class AuthMiddleware implements Middleware
{
    /** @param list<string> $tokenSah */
    public function __construct(private readonly array $tokenSah) {}

    public function handle(Request $request, callable $next): Response
    {
        if (!in_array($request->header('Authorization'), $this->tokenSah, true)) {
            return new Response('Unauthorized', [], 401);
        }

        return $next($request);
    }
}

$log = new TraceLog();
$core = static fn (Request $r): Response => new Response('dashboard admin');

$pipeline = new Pipeline();
$pipeline->add(new AuthMiddleware(['Bearer token-rahasia']));
$pipeline->add(new TraceMiddleware('Audit', $log));

$ditolak = $pipeline->run(new Request('GET', '/admin'), $core);
echo "ditolak   -> status {$ditolak->status}, audit: {$log->count()} baris\n";

$diizinkan = $pipeline->run(
    new Request('GET', '/admin', ['Authorization' => 'Bearer token-rahasia']),
    $core,
);
echo "diizinkan -> status {$diizinkan->status}, audit: {$log->count()} baris\n";

Output yang Dihasilkan:

text
ditolak   -> status 401, audit: 0 baris
diizinkan -> status 200, audit: 2 baris

Dua hal terbukti dari output tersebut. Pertama, request tanpa token mendapat status 401 — AuthMiddleware mengembalikan Response penolakan tanpa memanggil $next. Kedua, jumlah baris audit membuktikan efek domino: saat ditolak, Audit (yang berada di dalam Auth) tidak pernah tereksekusi sehingga log tetap nol baris; saat diizinkan, Audit berjalan normal dan mencatat dua baris (masuk dan keluar). Handler inti pun sama sekali tidak dieksekusi pada kasus ditolak.

Middleware Global dan Middleware Rute #

Framework nyata membedakan dua tempat pemasangan: middleware global yang berlaku untuk semua rute, dan middleware rute yang hanya dipasang pada rute tertentu. Kita tiru perilaku tersebut dengan class App kecil yang menggabungkan kedua kelompok sebelum menjalankan pipeline:

php
<?php

declare(strict_types=1);

require __DIR__ . '/pipeline.php';

final class App
{
    private readonly Pipeline $global;

    /** @var array<string, list<Middleware>> */
    private array $perRute = [];

    public function __construct()
    {
        $this->global = new Pipeline();
    }

    public function registerGlobal(Middleware $middleware): void
    {
        $this->global->add($middleware);
    }

    public function registerOnRoute(string $path, Middleware $middleware): void
    {
        $this->perRute[$path][] = $middleware;
    }

    public function handle(Request $request, callable $core): Response
    {
        $pipeline = new Pipeline();
        $pipeline->add(...$this->global->layers());

        foreach ($this->perRute[$request->path] ?? [] as $middleware) {
            $pipeline->add($middleware);
        }

        return $pipeline->run($request, $core);
    }
}

final class AuthMiddleware implements Middleware
{
    /** @param list<string> $tokenSah */
    public function __construct(private readonly array $tokenSah) {}

    public function handle(Request $request, callable $next): Response
    {
        if (!in_array($request->header('Authorization'), $this->tokenSah, true)) {
            return new Response('Unauthorized', [], 401);
        }

        return $next($request);
    }
}

$log = new TraceLog();
$core = static fn (Request $r): Response => new Response("isi {$r->path}");

$app = new App();
$app->registerGlobal(new TraceMiddleware('Logging', $log));
$app->registerOnRoute('/admin', new AuthMiddleware(['Bearer token-rahasia']));

foreach (['/about', '/admin'] as $path) {
    $response = $app->handle(new Request('GET', $path), $core);
    echo "$path -> status {$response->status}, log: {$log->count()} baris\n";
}

Output yang Dihasilkan:

text
/about -> status 200, log: 2 baris
/admin -> status 401, log: 4 baris

Output tersebut memperlihatkan pembagian tugas dengan jelas. Rute /about hanya melewati middleware global Logging dan berhasil dengan status 200. Rute /admin melewati Logging DAN middleware rute AuthMiddleware — karena request tidak membawa token, hasilnya 401, sementara Logging tetap mencatat (jumlah log bertambah dua baris: masuk dan keluar). Inilah cara framework membiarkan halaman publik bebas pemeriksaan login sementara halaman admin dilindungi tanpa menulis ulang kode pemeriksaan.

Ringkasan #

Middleware membungkus handler seperti lapisan bawang: request menembus dari lapisan terluar ke handler, response mengalir kembali keluar. Kontraknya sederhana — sebuah method yang menerima request dan callable $next — tetapi konsekuensinya kuat: lapisan bisa memproses kedua arah, memperkaya response, atau menolak request lebih awal lewat short-circuit. Urutan pemasangan menentukan lapisan mana yang berjalan lebih dulu, dan pemasangan bisa bersifat global maupun per rute. Dengan lapisan pelindung ini di tempatnya, kita siap merapikan isi handler itu sendiri — topik yang kita bahas pada chapter Controller Layer .

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

pipeline.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Kerangka inti chapter Middleware — pipeline berlapis bergaya model bawang.
 *
 * Berkas ini berisi seluruh kelas pendukung yang dipakai contoh-contoh
 * chapter; setiap contoh memuatnya dengan require.
 */

interface Middleware
{
    /**
     * Memproses request, lalu meneruskannya ke lapisan berikutnya
     * lewat callable $next, dan mengembalikan Response.
     */
    public function handle(Request $request, callable $next): Response;
}

final class Request
{
    public function __construct(
        public readonly string $method,
        public readonly string $path,
        public readonly array $headers = [],
    ) {}

    public function header(string $name): string
    {
        return $this->headers[$name] ?? '';
    }
}

final class Response
{
    public function __construct(
        public readonly string $body,
        public readonly array $headers = [],
        public readonly int $status = 200,
    ) {}

    public function withHeader(string $name, string $value): static
    {
        return new self($this->body, [...$this->headers, $name => $value], $this->status);
    }
}

final class TraceLog
{
    /** @var list<string> */
    public array $lines = [];

    public function record(string $line): void
    {
        $this->lines[] = $line;
    }

    public function count(): int
    {
        return count($this->lines);
    }

    public function __toString(): string
    {
        return implode("\n", $this->lines);
    }
}

final class TraceMiddleware implements Middleware
{
    public function __construct(
        private readonly string $name,
        private readonly TraceLog $log,
    ) {}

    public function handle(Request $request, callable $next): Response
    {
        $this->log->record("{$this->name}: request masuk");
        $response = $next($request);
        $this->log->record("{$this->name}: response keluar");
        return $response;
    }
}

final class Pipeline
{
    /** @var list<Middleware> */
    private array $layers = [];

    public function add(Middleware ...$layers): void
    {
        foreach ($layers as $layer) {
            $this->layers[] = $layer;
        }
    }

    /** @return list<Middleware> */
    public function layers(): array
    {
        return $this->layers;
    }

    public function run(Request $request, callable $core): Response
    {
        $next = $core;

        // Bungkus dari dalam ke luar: layer yang didaftarkan paling akhir
        // berada paling dekat dengan handler inti.
        foreach (array_reverse($this->layers) as $layer) {
            $next = static fn (Request $r): Response => $layer->handle($r, $next);
        }

        return $next($request);
    }
}