You upload an image through your admin panel, the record saves, and the file is genuinely on disk — but the page shows a broken image. You run the usual fix and get nothing useful back:
php artisan storage:link
ERROR symlink(): Cannot create symlink, error code(1)
This is not a permissions problem and re-running it will not help. Most shared hosts disable PHP's symlink() function outright.
Why the link exists at all
Laravel keeps uploads in storage/app/public, which sits outside the web root on purpose — nothing there is reachable until you say so. storage:link creates a symlink at public/storage pointing back to it, so asset('storage/photo.jpg') resolves.
Confirm the function is blocked before assuming anything else:
php -r "var_dump(function_exists('symlink'));"
php -i | grep disable_functions
If symlink appears in disable_functions, no amount of chmod will help.
The fix: point the disk at a directory that is already public
Instead of linking into the web root, tell the public disk to write there in the first place. Make the root configurable in config/filesystems.php:
'public' => [
'driver' => 'local',
'root' => env('FILESYSTEM_PUBLIC_ROOT')
? base_path(env('FILESYSTEM_PUBLIC_ROOT'))
: storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
],
Then in production only:
FILESYSTEM_PUBLIC_ROOT=public/storage
Locally the variable is unset, so everything behaves normally with the usual symlink. On the server files land in public/storage, which the web server already serves, and asset('storage/...') resolves to exactly the same URL it always did. No application code changes.
Create the directory and check it
mkdir -p public/storage
chmod -R 775 public/storage
Then prove it end to end rather than trusting it:
php artisan tinker
>>> Storage::disk('public')->put('test.txt', 'ok');
>>> Storage::disk('public')->path('test.txt');
Request /storage/test.txt in a browser. A 200 means uploads work. Delete the file afterwards.
The trap: blocking storage too aggressively
If your app lives inside public_html you have probably denied web access to storage/ — sensible, since that is where logs and sessions live. But the public URL for uploads is also /storage/..., so a blanket rule returns 403 for every image.
Deny the internals individually rather than the whole directory. Put a .htaccess containing Require all denied inside storage/logs, storage/framework and storage/app, and leave storage/ itself alone. Then confirm both halves:
/storage/test.txt→ 200/storage/logs/laravel.log→ 403
Things to know before you ship this
- Exclude uploads from deployment. They now live under
public/, so a sync with--deletewill happily remove every client image. Excludepublic/storageexplicitly. - Back them up. Uploads are outside version control, so nothing else is protecting them.
- Local behaviour is unchanged. The variable is production-only, so nobody on the team has to think about it.
This is one of a handful of things that catch people out on cheap hosting. The rest are collected in my full guide to deploying Laravel on Hostinger shared hosting.