Menyajikan berkas sebagai unduhan tampak sepele sampai detail kecil mulai ditanya: bagaimana memaksa browser menyimpan alih-alih membuka, bagaimana mengatur nama berkas yang diterima pengguna, dan bagaimana menambah header pelacak. Laravel punya dua pintu untuk semua itu — Storage::download() dari sisi filesystem dan response()->download() dari sisi HTTP — dan keduanya kita bedah berikut dengan header hasil nyatanya.

Storage::download: Unduhan dari Disk #

Method ini membaca berkas dari disk dan membungkusnya sebagai response unduhan. Panggilan paling sederhana cukup satu argumen path relatif; argumen kedua mengganti nama berkas yang diterima pengguna, dan argumen ketiga menambah header kustom:

php
use Illuminate\Support\Facades\Storage;

Storage::disk('local')->put('users/1/laporan-2024.pdf', '%PDF-1.4 isi laporan');

// 1) download dengan nama default (basename path)
$r1 = Storage::disk('local')->download('users/1/laporan-2024.pdf');
echo 'tanpa nama     : '.$r1->headers->get('Content-Disposition')."\n";
echo 'content-type   : '.$r1->headers->get('Content-Type')."\n";
echo 'content-length: '.$r1->headers->get('Content-Length')."\n";

// 2) download dengan nama spesial + header kustom
$r2 = Storage::disk('local')->download(
    'users/1/laporan-2024.pdf',
    'Laporan Final Budi 2024.pdf',
    ['X-Kategori' => 'dokumen-internal']
);
echo 'nama spesial   : '.$r2->headers->get('Content-Disposition')."\n";
echo 'header kustom  : '.$r2->headers->get('X-Kategori')."\n";

// 3) response inline untuk pembanding
$r3 = Storage::disk('local')->response('users/1/laporan-2024.pdf');
echo 'inline         : '.$r3->headers->get('Content-Disposition')."\n";

Output yang Dihasilkan:

text
tanpa nama     : attachment; filename=laporan-2024.pdf
content-type   : application/pdf
content-length: 20
nama spesial   : attachment; filename="Laporan Final Budi 2024.pdf"
header kustom  : dokumen-internal
inline         : inline; filename=laporan-2024.pdf

Empat perilaku terbaca langsung dari header di atas. Content-Disposition: attachment memerintahkan browser menyimpan berkas alih-alih membukanya, sementara inline pada Storage::response() menampilkannya langsung di jendela browser. Nama berkas yang mengandung spasi otomatis dibungkus tanda kutip agar tetap valid sebagai header HTTP. Content-Type: application/pdf ditebak Laravel dari isi berkas, dan Content-Length: 20 memuat ukuran persisnya — pengguna melihat progres unduhan yang benar berkat header ini.

Nama Unduhan di Controller #

Di route unduhan, pola lengkapnya satu method. Controller berikut menyalin pola berjaga dari chapter sebelumnya — otorisasi Gate dulu, keberadaan berkas kemudian — lalu mengirim Storage::download() dengan nama bersih:

php
public function unduh(Request $request, string $path)
{
    abort_unless(
        $request->user()
            && Gate::forUser($request->user())->allows('membaca-berkas', $path),
        403
    );
    abort_unless(Storage::exists($path), 404);

    return Storage::download($path, basename($path), ['X-Kategori' => 'dokumen-internal']);
}

basename($path) menjamin pengguna hanya menerima nama berkasnya — users/1/laporan-2024.pdf menjadi laporan-2024.pdf di dialog unduhan, bukan path internal yang justru membocorkan struktur folder.

Berkas Sementara: deleteFileAfterSend #

Banyak skenario menulis berkas sementara dulu ke disk — hasil eksport, PDF yang digenerate — lalu mengirimkannya sekali pakai. response()->download() bekerja pada path absolut dan menyediakan deleteFileAfterSend() untuk membersihkan berkas begitu response selesai dikirim:

php
public function test_delete_file_after_send_menghapus_berkas_sementara(): void
{
    Storage::fake('local');
    Storage::disk('local')->put('tmp/laporan-tmp.pdf', '%PDF-1.4 sementara');

    $berkas = Storage::disk('local')->path('tmp/laporan-tmp.pdf');
    $this->assertFileExists($berkas);

    $response = response()->download($berkas)->deleteFileAfterSend();

    ob_start();
    $response->sendContent();
    ob_end_clean();

    $this->assertFileDoesNotExist($berkas);
}

Test di atas memanggil sendContent() secara manual (dibungkus output buffering agar tidak mencemari keluaran test) karena penghapusan baru terjadi ketika isi response benar-benar dikirim. Hasilnya: sebelum kirim, berkas ada; setelah kirim, berkas lenyap — tidak ada sampah menumpuk di storage/app/private/tmp.

Streaming untuk Berkas Besar #

Storage::download() sebenarnya sudah streaming: isi berkas mengalir dari disk ke koneksi tanpa pernah dimuat penuh ke memori. Inilah sebabnya method ini mengembalikan StreamedResponse — closure pengirim dieksekusi setelah response mulai dikirim, bukan saat dibuat. Untuk konten yang bukan berkas — data yang dihasilkan bertahap, seperti eksport CSV yang dirakit baris demi baris — response()->stream() menerima closure yang menulis potongan demi potongan:

php
public function test_stream_response_mengirim_potongan_tanpa_memuat_semua(): void
{
    $response = response()->stream(function () {
        echo 'potongan-1';
        echo 'potongan-2';
    }, 200, ['Content-Type' => 'text/plain', 'X-Stream' => 'aktif']);

    ob_start();
    $response->sendContent();
    $isi = ob_get_clean();

    $this->assertSame('potongan-1potongan-2', $isi);
    $this->assertSame('aktif', $response->headers->get('X-Stream'));
}

Closure pada response()->stream() menggantikan isi berkas: dua echo di atas bisa diganti ribuan penulisan baris CSV dari kursor database, dan memori proses tetap rata karena setiap potongan langsung diteruskan ke koneksi alih-alih ditumpuk dalam satu string raksasa.

Menguji Header Unduhan #

Feature test untuk route unduhan memverifikasi tiga hal sekaligus: isi berkas utuh terkirim, disposition bernilai attachment dengan nama benar, dan header kustom ikut terbawa:

php
public function test_unduh_mengembalikan_response_attachment_dengan_header(): void
{
    Storage::fake('local');
    $budi = User::factory()->create(['id' => 1, 'name' => 'Budi']);
    Storage::disk('local')->put('users/1/laporan-2024.pdf', '%PDF-1.4 isi laporan');

    $response = $this->actingAs($budi)->get('/unduh/users/1/laporan-2024.pdf');

    $response->assertOk();
    $this->assertSame('%PDF-1.4 isi laporan', $response->streamedContent());

    $this->assertSame(
        'attachment; filename=laporan-2024.pdf',
        $response->headers->get('Content-Disposition')
    );
    $this->assertSame('application/pdf', $response->headers->get('Content-Type'));
    $this->assertSame('dokumen-internal', $response->headers->get('X-Kategori'));
}

Output yang Dihasilkan:

text
   PASS  Tests\Feature\UnduhBerkasTest
  ✓ unduh mengembalikan response attachment dengan header              0.17s
  ✓ delete file after send menghapus berkas sementara                  0.01s
  ✓ stream response mengirim potongan tanpa memuat semua               0.01s

  Tests:    3 passed (10 assertions)
  Duration: 0.26s

Ketiga test pada kelas UnduhBerkasTest memenuhi seluruh klaim chapter ini: header unduhan terverifikasi, berkas sementara terhapus setelah kirim, dan streaming bekerja potongan demi potongan.

Unduhan yang kita bangun selama ini berumur selamanya selama pengguna login. Bagaimana bila link unduhan perlu dikirim lewat email dan kedaluwarsa dalam hitungan menit? Itu pekerjaan Temporary URLs, chapter berikutnya — dengan temporaryUrl() dan signed route di dokumentasi Responses serta dokumentasi Filesystem sebagai rujukan resmi.