← Back to all articles laravel

Fix Laravel storage:link When symlink() Is Disabled

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.

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 --delete will happily remove every client image. Exclude public/storage explicitly.
  • 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.

Found this useful?

I write these from real client projects. Have one that needs building?

Start a project

Keep reading