Reader Stacks

Accessing and Serving Uploaded Files From Laravel Storage

A file stored on the public disk isn't automatically web-accessible until the storage:link symlink exists — the single most common cause of a broken image path after upload.

Accessing and Serving Uploaded Files From Laravel Storage

A file successfully stored via Storage::disk('public')->put() still isn't accessible from a browser until one specific setup step is done — this single missing step is the most common cause of "the upload worked but the image shows broken" after a fresh deploy.

Where uploaded files actually live

Files stored on the public disk are physically saved under storage/app/public/ — but Laravel's web server document root is public/, an entirely different directory, which is exactly why a symlink between the two is necessary.

Creating the storage symlink

php artisan storage:link

This creates public/storage as a symbolic link pointing to storage/app/public — without running this command (typically once, right after a fresh deploy or local setup), every file stored on the public disk remains genuinely inaccessible over HTTP, even though it exists correctly on disk.

Generating the correct public URL for a stored file

$path = $request->file('photo')->store('avatars', 'public');
// $path is something like "avatars/xyz123.jpg"

$url = Storage::url($path); // "/storage/avatars/xyz123.jpg"

Using Storage::url() rather than manually concatenating the path is what keeps this working correctly regardless of the disk's actual configured root — hardcoding /storage/ yourself works today but breaks silently if the disk configuration ever changes.

Why this step is easy to forget on a fresh server

The symlink is a filesystem-level artifact, not something tracked in version control or created automatically by composer install — a fresh deployment to a new server (or a new local development environment) needs php artisan storage:link run explicitly as part of the setup process, which is a common oversight when deployment scripts aren't kept in sync with this requirement.

Verifying the symlink actually exists

ls -la public/storage

This should show it as a symbolic link pointing to ../storage/app/public — if it's a regular directory instead, or missing entirely, that confirms storage:link hasn't been run (or previously failed) on that environment.

Serving a private file that shouldn't be publicly accessible

Route::get('/invoices/{invoice}/download', function (Invoice $invoice) {
    abort_unless($invoice->user_id === auth()->id(), 403);

    return Storage::disk('local')->download($invoice->file_path);
})->middleware('auth');

For files that need access control (an invoice only its owner should download), storing them on the local disk (not public, and with no symlink pointing to it) and serving them through an authenticated route — rather than a direct public URL — is the correct approach, since anything under public/storage is accessible to anyone with the URL, with no authentication check possible.