Storage::url() returns a 404 and the file is right there
A missing file and an unservable file look identical from the outside — and in production, so does a file you are simply not allowed to see.
ON THIS PAGE
cat works, curl does not
An upload succeeds. The row is written, the filename is right, and Storage::disk('public')->exists($path) returns true. You SSH into the box and cat the file. It is there, correct bytes, correct permissions.
The browser shows a broken image icon, and the access log shows a 404.
The reason this costs people an afternoon is that Storage::url() is not a lookup. It does no existence check and never touches the filesystem — it is string concatenation against a configured base, so it happily hands you a URL for a file that was deleted last year and an unreachable URL for a file that is sitting right there. Three unrelated mechanisms end in the same 404, and they need three different fixes.
The symlink is absolute, and your deploy moved
The public disk stores files in storage/app/public, which is outside the web root. The bridge is a symlink:
php artisan storage:link
Look at what that actually created:
lrwxr-xr-x 1 deploy staff 60 public/storage -> /var/www/releases/20260914093011/storage/app/public
That target is absolute, and it names one specific release directory. If you deploy by building a new release directory and swinging a current symlink at it — Envoyer, Deployer, Capistrano, most hand-rolled zero-downtime scripts — then the link inside the new release either does not exist yet, or was committed/copied from an older build and still points into a release you are about to delete. Either way, files uploaded through the running application land in the shared storage directory and the web server looks for them somewhere else.
The usual arrangement is a shared storage directory symlinked into each release, and storage:link run as a deploy step after that. Which brings us to the part that hides the failure:
php artisan storage:link
Run it a second time and it tells you something looks wrong:
ERROR The [public/storage] link already exists.
That is printed on stderr, it says ERROR, and the command exits 0. Verify it yourself:
php artisan storage:link > /dev/null 2>&1; echo "exit: $?"
The shell disagrees with the word ERROR:
exit: 0
So a deploy script with set -e sails straight past it. The link that "already exists" is the stale one pointing at a deleted release, and nothing in your deploy output distinguishes that from success. If you want the link replaced on every deploy, say so explicitly:
php artisan storage:link --force
And if you want to know, rather than assume, add an assertion that reads the link rather than trusting the command:
readlink -f public/storage
The URL is built from APP_URL, so a wrong APP_URL is a wrong host
The second mechanism is not about files at all. Look at how the public disk defines its base:
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
],
Storage::disk('public')->url() is that string plus your path. Nothing more:
Storage::disk('public')->url('avatars/x.png');
On a development machine that returns:
http://localhost:8000/storage/avatars/x.png
That is correct output for a development machine. It is also exactly what production returns when APP_URL was never set there — every image on the site points at the visitor's own machine, which 404s or refuses the connection depending on what they happen to be running locally. The same mechanism produces mixed-content warnings when APP_URL is http:// behind a TLS-terminating proxy, and broken images on a staging clone whose .env still names the production host.
This one is worth a line in whatever checks your deploy already runs, because the symptom is indistinguishable from the symlink problem and the fix is in a completely different file:
php artisan config:show app.url
Note too that the trap applies to config:cache: if APP_URL changes and the config cache is not rebuilt, the old base is baked into the cached array. That is the same hazard as every other cached env() read, which this site covers separately in the piece on config:cache and env() calls.
In production, "forbidden" and "missing" are the same response
The third mechanism is the one that produces the most confused bug reports, and it only applies to private files.
A disk using the local driver with serve enabled does not need a symlink. Laravel registers a route for it instead:
GET|HEAD storage/{path} storage.local
Now Storage::disk('local')->url() returns a path into that route, and because the disk's visibility is private, the controller behind it requires a valid signature:
Storage::disk('local')->url('contracts/x.pdf');
Storage::disk('local')->temporaryUrl('contracts/x.pdf', now()->addMinutes(5));
The two calls return very different things:
/storage/contracts/x.pdf
http://localhost:8000/storage/contracts/x.pdf?expires=1790875672&signature=3510816ee6ab…
Two things to notice. url() on a private disk gives you an unsigned URL, which will never work — you need temporaryUrl(). And the signed one carries an expiry, so a link pasted into a Slack message, stored in a database column, or emailed in a digest stops working at a wall-clock time unrelated to whether the file exists.
Here is the part that turns this into an afternoon. The controller aborts with a different status depending on the environment:
abort_unless(
$this->hasValidSignature($request),
$this->isProduction ? 404 : 403
);
Locally, a missing or expired signature gives you a 403 — a clear "you are not allowed", which points at the signature. In production the same condition gives you a 404, deliberately, so the endpoint does not confirm which paths exist. That is good security and terrible debugging: an expired link, a link whose signature was mangled by a URL-rewriting email client, a link generated before APP_KEY was rotated, and a genuinely deleted file all return byte-identical 404s. Your staging environment, if APP_ENV is not production, will cheerfully tell you it was a signature problem — which is why "it works on staging" is a real data point here rather than a brush-off.
The other half of this is the inverse mistake: reaching for temporaryUrl() on a disk that cannot sign anything. On an unsupported driver you get a hard failure rather than a 404:
RuntimeException: This driver does not support creating temporary URLs.
If you need signed URLs from a driver that has no native concept of them, Storage::disk($name)->buildTemporaryUrlsUsing() in a service provider lets you route them through a controller of your own.
What to change
Start with the one diagnostic that separates all three causes, because they are indistinguishable from the browser. On the production box:
readlink -f public/storage
php artisan config:show app.url
If the symlink target is not the shared storage directory the running release actually writes to, you have cause one — add storage:link --force to the deploy and stop trusting its exit code. If app.url is not the public URL of that environment, you have cause two, and remember to rebuild the config cache after fixing it.
If both are right and private files are still 404ing, you have cause three: check whether the code is calling url() where it needs temporaryUrl(), and check whether any signed URL is being persisted anywhere — a column, a cache entry, an email body, a queued notification. A signed URL is a credential with an expiry, not an address. Generate it at the moment it is served, never before.