Berkas sudah tersimpan rapi di disk public, tetapi menyimpannya saja tidak cukup: avatar yang tidak bisa dimuat browser tidak berguna bagi siapa pun. Persoalannya sederhana namun mendasar — folder storage/app/public berada di luar folder public/ yang diekspos web server, sehingga secara default tidak ada URL mana pun yang mengarah ke sana. Chapter ini membangun jembatan tersebut: perintah storage:link, helper url()/asset(), serta pembuktian lewat test bahwa berkas memang bisa dijangkau.

Laravel tidak menyalin berkas ke folder publik — itu akan menduplikasi data dan cepat tidak sinkron. Solusinya satu symbolic link (symlink) yang memetakan public/storage ke storage/app/public:

bash
php artisan storage:link

Output yang Dihasilkan:

text
   INFO  The [public/storage] link has been connected to [storage/app/public].

Setelah perintah itu, berkas storage/app/public/avatar-budi.png dapat dijangkau lewat path public/storage/avatar-budi.png — dan web server menyajikannya persis seperti berkas statis biasa. Lokasi link ini pun terdaftar di config/filesystems.php pada key links, jadi bila aplikasi memakai struktur folder berbeda, mapping-nya bisa diubah di sana.

Storage::url dan asset() #

Dua cara memberi path berkas kepada browser. Storage::url($path) milik disk public mengembalikan URL lengkap berdasarkan key url pada konfigurasi disk:

php
use Illuminate\Support\Facades\Storage;

Storage::disk('public')->put('avatar-budi.png', 'isi gambar');

echo Storage::disk('public')->exists('avatar-budi.png')
    ? "berkas ada\n" : "berkas tidak ada\n";
echo 'url   : '.Storage::disk('public')->url('avatar-budi.png')."\n";
echo 'asset : '.asset('storage/dokumen/proposal-final.pdf')."\n";

Output yang Dihasilkan:

text
berkas ada
url   : http://localhost:8000/storage/avatar-budi.png
asset : http://localhost:8000/storage/dokumen/proposal-final.pdf

Perbedaannya: Storage::url() menghitung URL dari konfigurasi disk — path relatif disk langsung menjadi bagian URL. Helper asset() membangun URL dari folder public/, sehingga prefiks storage/ harus kita tulis sendiri untuk berkas yang terekspos lewat symlink. Di dalam template, keduanya biasa dipakai seperti ini:

blade
<img src="{{ Storage::disk('public')->url('avatar-budi.png') }}" alt="Avatar Budi">

<a href="{{ asset('storage/dokumen/proposal-final.pdf') }}">Unduh proposal</a>

Dari dua pilihan itu, Storage::url() lebih tahan terhadap perubahan arsitektur: bila suatu saat disk public dipindahkan ke S3, key url pada konfigurasi cukup diganti dan semua tautan ikut menyesuaikan, sedangkan asset('storage/...') terpaku pada asumsi symlink. Pakai asset() untuk aset front-end statis (CSS, JS build) yang memang selalu hidup di folder public/, dan Storage::url() untuk berkas yang dikelola aplikasi.

Mari kita buktikan dampak symlink lewat test — bukan sekadar percaya bahwa perintahnya “berhasil”. Test berikut me-reset symlink lebih dulu, memastikan berkas memang tidak terlihat, menjalankan storage:link, lalu memeriksa lagi:

php
namespace Tests\Feature;

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

class MenampilkanFileTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();

        // reset symlink agar skenario "belum storage:link" bisa diuji
        if (is_link(public_path('storage'))) {
            @unlink(public_path('storage'));
        }
    }

    public function test_sebelum_symlink_berkas_tidak_terlihat_dari_public(): void
    {
        Storage::disk('public')->put('avatar-budi.png', 'isi gambar');

        $this->assertFileDoesNotExist(public_path('storage/avatar-budi.png'));

        // tanpa symlink, request /storage/... ditangani serve route disk local
        // yang menuntut tanda tangan → 403 di lingkungan non-production
        $this->get('/storage/avatar-budi.png')->assertForbidden();
    }

    public function test_sesudah_storage_link_berkas_terlihat_dari_public(): void
    {
        Storage::disk('public')->put('avatar-budi.png', 'isi gambar');

        Artisan::call('storage:link');

        // symlink membuat berkas terlihat persis seperti berkas di folder public
        $this->assertFileExists(public_path('storage/avatar-budi.png'));
        $this->assertSame(
            'isi gambar',
            file_get_contents(public_path('storage/avatar-budi.png'))
        );
    }
}

Output yang Dihasilkan:

text
   PASS  Tests\Feature\MenampilkanFileTest
  ✓ sebelum symlink berkas tidak terlihat dari public               0.16s
  ✓ sesudah storage link berkas terlihat dari public                 0.01s

  Tests:    2 passed (4 assertions)
  Duration: 0.24s

Dua temuan penting dari test tersebut. Pertama, assertFileDoesNotExist() membuktikan bahwa tanpa symlink, tidak ada public/storage/avatar-budi.png — web server tidak punya apa pun untuk disajikan. Kedua, assertFileExists() setelah storage:link membuktikan mekanisme sebaliknya: isi berkas terbaca identik melalui path publik, yang berarti web server dapat menyajikannya sebagai berkas statis tanpa menyentuh PHP sama sekali.

Lalu mengapa request /storage/avatar-budi.png sebelum link menghasilkan 403, bukan 404? Sejak Laravel 11.23, framework otomatis mendaftarkan route GET storage/{path} untuk disk local (opsi serve di konfigurasinya). Request tanpa symlink jatuh ke route bertanda tangan itu, dan karena URL tidak bertanda tangan valid, Laravel menolaknya. Detail cara kerja route ini kita bahas tuntas di chapter Temporary URLs.

Yang Tidak Bisa Disajikan Begitu Saja #

Seluruh mekanisme di chapter ini hanya berlaku untuk disk public. Berkas di disk local — faktur, dokumen kontrak, data pribadi — memang tidak punya jalur URL sama sekali, dan menyajikannya butuh keputusan bisnis: siapa boleh melihat apa. Otorisasi semacam itu tidak bisa diwujudkan dengan symlink; ia butuh route, controller, dan policy — materi chapter File Privat dan Publik. Panduan resmi penyajian berkas tersedia di dokumentasi Filesystem .