Section middleware
sudah mengajari kita membuat dan mendaftarkan middleware. Praktik nyata menuntut lebih dari sekadar lapisan handle(): kita perlu mencatat audit setelah response terkirim, menjamin urutan eksekusi lintas middleware yang dipasang di banyak route, menyusun filter yang bisa dipakai ulang tanpa menulis ulang parameter, memasang middleware hanya pada lingkungan tertentu, dan menguji semua itu tanpa harus menyalakan server. Chapter ini membahas lima pola lanjutan tersebut, semuanya dibuktikan lewat aplikasi latihan.
Kerja Setelah Response: terminate() #
Middleware biasa berhenti begitu $next($request) mengembalikan response. Padahal pekerjaan seperti menulis audit log atau mengukur durasi request baru lengkap setelah response selesai dibentuk. Laravel menyediakan method kedua untuk ini: terminate(). Kernel memanggil method tersebut setelah response dikirim ke browser, sehingga pekerjaan berat tidak ikut menunda pengguna. Kita buktikan dengan middleware audit yang mencatat durasi total:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AuditTrail
{
public function handle(Request $request, Closure $next): Response
{
// catat waktu masuk pipeline; dipakai terminate() untuk durasi total
$request->attributes->set('audit_mulai', microtime(true));
return $next($request);
}
public function terminate(Request $request, Response $response): void
{
$ms = (microtime(true) - (float) $request->attributes->get('audit_mulai')) * 1000;
$baris = sprintf("%s /%s -> %s (%.1f ms)\n",
$request->getMethod(), $request->path(), $response->getStatusCode(), $ms);
file_put_contents(storage_path('logs/audit-trail.log'), $baris, FILE_APPEND);
}
}Route /kas memakai middleware ini, dengan usleep(50000) di controller untuk mensimulasikan agregasi query berat. Runner berikut menaikkan request secara manual lewat kernel HTTP supaya urutan handle() dan terminate() terlihat telanjang:
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$request = Illuminate\Http\Request::create('/kas', 'GET');
$response = $kernel->handle($request);
echo 'response : status=', $response->getStatusCode(), ' isi="', $response->getContent(), '"', PHP_EOL;
echo 'log audit: ', is_file($auditLog) ? 'sudah ada' : 'belum ada', PHP_EOL;
$kernel->terminate($request, $response);
echo 'log audit: ', trim((string) file_get_contents($auditLog)), PHP_EOL;Output yang Dihasilkan:
response : status=200 isi="Ringkasan kas"
log audit: belum ada
log audit: GET /kas -> 200 (55.4 ms)Angka 55,4 ms mencakup usleep 50 ms di controller — bukti terminate() menunggu seluruh request selesai, bukan hanya bagian middleware. Di server sungguhan, urutannya sama persis: handle() saat masuk, response dikirim, baru terminate() berjalan. Detail siklus lengkapnya ada di dokumentasi lifecycle
, sementara daftar method middleware ada di dokumentasi middleware
.
Mengunci Urutan lewat Daftar Prioritas #
Dua middleware di satu route dieksekusi berurutan seperti onion, tetapi ketika filter datang dari global list, group, dan route sekaligus, urutan deklarasinya tidak lagi bisa diandalkan. Laravel mengatasinya dengan priority list: framework menyortir semua middleware yang terdaftar di daftar itu menurut posisinya. Method $middleware->priority([...]) mengganti daftar tersebut secara penuh, jadi kita salin dulu daftar bawaan framework lalu menyisipkan dua lapisan demo di akhirnya:
$middleware->priority([
\Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class,
\Illuminate\Cookie\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class,
\Illuminate\Routing\Middleware\ThrottleRequestsWithRedis::class,
\Illuminate\Contracts\Session\Middleware\AuthenticatesSessions::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\Illuminate\Auth\Middleware\Authorize::class,
\App\Http\Middleware\LapisAwal::class,
\App\Http\Middleware\LapisAkhir::class,
]);Route /urutan sengaja mendeklarasikan LapisAkhir lebih dulu, padahal daftar prioritas menempatkan LapisAwal di atasnya. Hasilnya, urutan deklarasi kalah oleh urutan prioritas:
Output yang Dihasilkan:
Tanpa dua baris LapisAwal/LapisAkhir di daftar prioritas:
trace: akhir:sebelum > awal:sebelum > controller > awal:sesudah > akhir:sesudah
Dengan dua baris tersebut:
trace: awal:sebelum > akhir:sebelum > controller > akhir:sesudah > awal:sesudahGroup dan Pabrik Filter Reusable #
Filter yang selalu berbarengan sebaiknya diberi satu nama. Method group() di bootstrap/app.php menyatukan audit dan pemeriksaan peran menjadi group area-laporan:
$middleware->alias(['role' => EnsureRole::class]);
$middleware->group('area-laporan', [
AuditTrail::class,
'role:admin,editor',
]);Route cukup menulis ->middleware('area-laporan') dan seluruh set menempel. Sementara itu, parameter middleware ala string role:admin,editor bisa dibungkus pabrik agar deklarasi route tetap ekspresif. Method create() berikut meniru pola Authenticate::using() milik framework — mengembalikan string class:parameter, bukan instance:
public static function create(string ...$roles): string
{
return static::class.':'.implode(',', $roles);
}Saat kita mencoba mengembalikan closure dari pabrik tersebut pada percobaan awal, Laravel menolaknya mentah-mentah:
Error
Object of class Closure could not be converted to string
at vendor/laravel/framework/src/Illuminate/Routing/Route.php:1090Error ini nyata: method Route::middleware() melempkan setiap argumen ke (string), jadi hanya nama class, alias, atau string class:param yang diterima. Dengan bentuk string, hasil pabrik bekerja mulus di route:
create: App\Http\Middleware\EnsureRole:admin,editor
keuangan_editor: 200 isi="Ringkasan keuangan"
kas_admin: 200 isi="Ringkasan kas"
kas_penonton: 403Baris create: menunjukkan string terkonfigurasi yang dihasilkan pabrik, dua baris berikutnya membuktikan route /keuangan dengan hasil pabrik menolak atau menerima sesuai peran, dan dua baris terakhir menegaskan group area-laporan memang menyatukan audit plus filter peran di route /kas.
Middleware Kondisional dengan when() #
Terkadang middleware hanya relevan di lingkungan tertentu — penanda debug misalnya. Route memiliki method when() (warisan trait Conditionable) yang menempelkan middleware hanya bila kondisinya benar:
Route::get('/info-dev', fn () => 'Info khusus dev')
->when(! app()->isProduction(), fn (\Illuminate\Routing\Route $route) => $route->middleware(StampDev::class));Kondisi ini dievaluasi saat file route dimuat — saat booting — sehingga ia hanya boleh bergantung pada fakta config atau environment, bukan isi request yang belum ada. Runner yang sama menghasilkan dua hasil berbeda hanya karena nilai APP_ENV:
Output yang Dihasilkan:
APP_ENV : local
info-dev : status=200 header="aktif"
APP_ENV : production
info-dev : status=200 header="(tidak ada)"Perhatikan bahwa status tetap 200 di keduanya — yang berubah hanyalah kehadiran middleware. Pola ini cocok untuk middleware alat bantu yang tidak boleh ikut ke production.
Menguji Middleware Secara Terisolasi #
Feature test menguji banyak hal sekaligus; ketika middleware-nya yang dicurigai, test terisolasi lebih cepat dan pesan gagalnya lebih tajam. Karena middleware hanyalah class, kita bisa memanggil handle() langsung dengan Request buatan dan closure $next tanpa HTTP, tanpa route, tanpa database:
$middleware = new EnsureRole();
$budi = User::factory()->make(['name' => 'Budi', 'role' => 'editor']);
$request = Request::create('/keuangan', 'GET');
$request->setUserResolver(fn () => $budi);
$ok = $middleware->handle($request, fn () => response('lewat'), 'admin', 'editor');
try {
$middleware->handle(Request::create('/keuangan', 'GET'), fn () => response('lewat'), 'admin');
} catch (HttpException $e) {
echo 'tanpa_user: status=', $e->getStatusCode(), ' pesan="', $e->getMessage(), '"', PHP_EOL;
}Output yang Dihasilkan:
editor: status=200 isi="lewat"
tanpa_user: status=403 pesan="Peran tidak diizinkan: admin"Method setUserResolver() menggantikan mekanisme auth sungguhan dengan pengguna tetap, sehingga EnsureRole bisa diuji tanpa login. factory()->make() sengaja dipilih agar tanpa query database. Lima pola di atas membentuk perangkat lengkap: bekerja setelah response, mengunci urutan, menyusun ulang filter, memasang selektif, dan menguji cepat. Ketika pola seperti ini ingin dibagikan antar proyek, wadahnya bukan lagi folder app/ — melainkan paket composer, topik chapter berikutnya.