Email konfirmasi berisi “unduh faktur Anda di sini” punya masalah bawaan: link yang dikirim lewat email cenderung diteruskan, disimpan, dan hidup lebih lama dari yang kita mau. Solusinya adalah URL yang membawa masa berlaku sendiri — lewat masa itu, link mati otomatis tanpa perlu satu baris cleanup pun di database. Laravel menyediakan dua mekanisme untuk itu: Storage::temporaryUrl() untuk disk cloud, dan signed route untuk route milik kita sendiri. Keduanya kita uji dengan manipulasi waktu supaya kedaluwarsanya benar-benar terlihat.

temporaryUrl pada Disk S3 #

Untuk disk cloud seperti S3, temporaryUrl() menghasilkan presigned URL — URL yang ditandatangani kredensial AWS dan berlaku sampai batas waktu yang kita tentukan:

php
$url = Storage::disk('s3')->temporaryUrl(
    'faktur/2024/budi.pdf',
    now()->addMinutes(5)
);

// kirim $url lewat Mailable, tampil di halaman notifikasi, dst.

Kode di atas berfungsi penuh ketika disk s3 terkonfigurasi dan kredensialnya valid; di lingkungan Course ini kita tidak punya koneksi AWS, jadi contoh ditampilkan tanpa klaim output. Perhatikan polanya: tanggal kedaluwarsa dinyatakan sebagai instance Carbon — now()->addMinutes(5) berarti “lima menit dari sekarang”.

Disk local di Laravel 13: Route serve Bawaan #

Dulu, temporaryUrl() pada driver local melempar exception — driver lokal tidak punya presigned URL. Sejak Laravel 11.23 ceritanya berubah: opsi 'serve' => true pada konfigurasi disk local membuat framework mendaftarkan route bertanda tangan (storage/{path}) yang menyajikan berkas dari disk tersebut. temporaryUrl() kini mengembalikan URL menuju route itu lengkap dengan tanda tangan berjangka waktu:

php
use Illuminate\Support\Facades\Storage;

Storage::disk('local')->put('users/1/faktur-2024.txt', 'isi faktur Budi');

$url = Storage::disk('local')->temporaryUrl(
    'users/1/faktur-2024.txt',
    now()->addMinutes(5)
);

$bagian = parse_url($url);
echo 'path    : '.$bagian['path']."\n";
parse_str($bagian['query'] ?? '', $query);
echo 'query   : '.implode(', ', array_keys($query))."\n";

Output yang Dihasilkan:

text
path    : /storage/users/1/faktur-2024.txt
query   : expires, signature

Struktur URL-nya kini terbaca: path menuju berkas di bawah /storage, dengan dua parameter query — expires (batas waktu) dan signature (tanda tangan HMAC yang divalidasi terhadap APP_KEY). Nilai keduanya berubah setiap kali URL dibuat, tetapi bentuknya selalu seperti di atas.

Menguji Kedaluwarsa dengan travel() #

Test framework menyediakan $this->travel() untuk memindahkan waktu aplikasi ke depan tanpa menunggu sungguhan. Dengan itu, kedua sisi umur URL bisa diuji dalam satu test:

php
namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

class UrlSementaraTest extends TestCase
{
    use RefreshDatabase;

    protected function tearDown(): void
    {
        Storage::disk('local')->deleteDirectory('users');
        parent::tearDown();
    }

    public function test_temporary_url_disk_local_valid_sebelum_kedaluwarsa(): void
    {
        Storage::disk('local')->put('users/1/faktur-2024.txt', 'isi faktur Budi');

        $url = Storage::disk('local')->temporaryUrl(
            'users/1/faktur-2024.txt',
            now()->addMinutes(5)
        );

        $response = $this->get($url);
        $response->assertOk();
        $this->assertSame('isi faktur Budi', $response->streamedContent());
    }

    public function test_temporary_url_disk_local_ditolak_setelah_kedaluwarsa(): void
    {
        Storage::disk('local')->put('users/1/faktur-2024.txt', 'isi faktur Budi');

        $url = Storage::disk('local')->temporaryUrl(
            'users/1/faktur-2024.txt',
            now()->addMinutes(5)
        );

        $this->travel(6)->minutes();

        $this->get($url)->assertForbidden();
    }
}

Output yang Dihasilkan:

text
   PASS  Tests\Feature\UrlSementaraTest
  ✓ temporary url disk local valid sebelum kedaluwarsa                   0.16s
  ✓ temporary url disk local ditolak setelah kedaluwarsa                 0.02s
  ✓ signed route kustom tanpa login sampai kedaluwarsa                   0.03s

  Tests:    3 passed (6 assertions)
  Duration: 0.28s

Test kedua adalah intinya: URL dibuat berumur 5 menit, lalu $this->travel(6)->minutes() memajukan waktu aplikasi melewati batas itu — request berikutnya ditolak 403 karena expires sudah terlampaui. Di lingkungan produksi, signature yang kedaluwarsa menghasilkan 404 (bukan 403) agar penyerang tidak mendapat konfirmasi bahwa route itu ada.

Signed Route untuk Route Kustom #

Mekanisme yang sama bisa dipasang pada route milik kita sendiri lewat middleware signed dan URL::temporarySignedRoute(). Route berikut menyajikan dokumen berdasarkan ID model — tanda tangannya menjadi satu-satunya kunci masuk:

php
use App\Models\Dokumen;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;

Route::get('/bagikan/dokumen/{dokumen}', function (Dokumen $dokumen) {
    return Storage::disk('local')->response($dokumen->path);
})->name('dokumen.bagikan')->middleware('signed');

Pembuatan link-nya terjadi di kode yang mengirim email atau notifikasi:

php
use Illuminate\Support\Facades\URL;

$url = URL::temporarySignedRoute(
    'dokumen.bagikan',
    now()->addMinutes(5),
    ['dokumen' => $dokumen->id]
);

Test ketiga pada output di atas membuktikan pola ini: tamu tanpa login dapat membuka URL valid (tanda tangan adalah otorisasinya — tidak perlu session), dan setelah travel(6)->minutes() link yang sama ditolak 403. Inilah yang membuatnya cocok untuk email: penerima tidak diminta login hanya untuk melihat faktur yang dikirimkan kepadanya.

Kapan Memilih Yang Mana #

  • Storage::temporaryUrl() — saat berkas memang tinggal di disk dan tidak butuh logika tambahan; di S3 ini satu-satunya cara karena berkas tidak disajikan lewat aplikasi sama sekali.
  • URL::temporarySignedRoute() — saat penyajian butuh keputusan kode: route model binding, pencatatan akses, atau isi dinamis.
  • Route ber-otorisasi + login — saat identitas penerima harus diverifikasi, bukan sekadar kepemilikannya link.

Berkas privat kini bisa dibagikan secara aman dan terbatas waktu. Satu pekerjaan rumah terakhir masih menunggu: berkas yang terus menumpuk — hasil eksport, upload yang dibatalkan, thumbnail usang — dan cara membersihkannya secara berkala, yang kita kerjakan di chapter File Cleanup. Rujukan resmi signed URL tersedia di dokumentasi URLs dan dokumentasi Filesystem .