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.