One of the most common walls anyone hits when taking Laravel to shared hosting is this: uploaded images do not show. The path is right, the file is on disk, but the browser gets a 404.

The cause is almost always the storage symlink. And on cPanel, this story has two separate problems that usually get mixed together.

First: what Laravel expects#

Laravel puts public uploaded files in storage/app/public. That folder is outside the web root, so the browser cannot read it directly. The familiar command fills that gap:

Bash
php artisan storage:link

What it does is simple: it creates a symlink from public/storage to storage/app/public. Now the URL /storage/image.jpg reaches the real file.

This works flawlessly on a VPS, because there the web root is the project's own public folder.

On many shared hosts, symlink is in the disable_functions list. That means the command above cannot work by design, no matter how correct the file permissions are.

See whether it is blocked:

Bash
php84 -r 'var_dump(function_exists("symlink"));'

If it returns false, no PHP trick will help. The full explanation of this restriction and the other blocked functions is here.

Problem two: even if it is open, the link is created in the wrong place#

This trap is subtler and more dangerous, because the command reports success and you think you are done.

On cPanel the usual structure is:

Code
/home/USER/
├── app/            ← the Laravel project, outside the web root
│   ├── public/
│   └── storage/app/public/
└── public_html/    ← the real web root

Laravel thinks public_path() is ~/app/public, because from its point of view that is the web root. So it creates the link at ~/app/public/storage.

But the web server never looks at ~/app/public. The web root is ~/public_html. The link was created, and created correctly, but in a path that no request ever reaches.

The result: the command says success, images still get 404, and you spend hours hunting for a file permission problem.

The rule that came out of this: on a host where the web root is not the project's public folder, do not run storage:link at all. Even when it works, it gives the wrong answer.

The fix: create the link manually#

The ln command is a system binary and does not care about PHP settings. From the cPanel terminal or SSH:

Bash
ln -s /home/USER/app/storage/app/public /home/USER/public_html/storage

A few points that matter:

Give absolute paths, not relative ones. A relative symlink is interpreted relative to the current folder and is confusing later.

If something is already there, remove it first. If public_html/storage is a real folder, the command above creates the link inside it and you end up with public_html/storage/public:

Bash
ls -la /home/USER/public_html/storage

Verify the result:

Bash
ls -la /home/USER/public_html/ | grep storage

You should see something like this:

Code
storage -> /home/USER/app/storage/app/public

And the final test, from the web's point of view:

Bash
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/storage/an-existing-file.jpg

This is the most important part of this post.

Laravel's storage folder holds more than uploaded images. Logs, cache files, sessions and — if you run a file store like we do — files for sale are there too.

Only storage/app/public should be linked. If you link the whole storage by mistake:

Bash
# a disastrous mistake
ln -s /home/USER/app/storage /home/USER/public_html/storage

then anyone can download /storage/logs/laravel.log, and your log is usually full of internal details. And if you have files for sale, they can be taken for free.

The structure we use is deliberately like this:

Code
storage/
├── app/public/     ← linked, public
└── products/       ← files for sale, never linked

Files for sale live on a separate disk that no web path reaches, and they are delivered only through a controller that checks the purchase permission.

The symlink is not the only link in the chain. If ls -la shows the correct link but the browser still cannot find the file, check in this order.

Apache needs the matching option to cross a symlink. If it is off on your account, the request gets 403, not 404. In the .htaccess file in the web root:

Apache
Options +FollowSymLinks

On some hosts this option is locked and changing it gives a 500 error. If the site throws an internal error after you add this line, remove that line and go to the approach without symlinks.

2. Do rewrite rules intercept the request?#

Laravel's default .htaccess sends everything to index.php, unless a real file exists:

Apache
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]

The first condition means "if a real file exists, do nothing". This also holds for files behind a symlink, so it is usually fine. But if you added custom rules, their order can send the request to Laravel, and Laravel has no route for /storage/... and returns 404.

That same !-f condition has a side trap that we fell into: if a zero-byte file with a name Laravel should serve stays in the web root, the request never reaches Laravel. An empty favicon.ico kept the favicon configured in the admin panel from ever showing, because Apache kept serving that empty file.

3. Do folder permissions allow it?#

The symlink is right, but if the target folder lacks execute (traverse) permission, the web server cannot pass through it:

Bash
ls -ld ~/app ~/app/storage ~/app/storage/app ~/app/storage/app/public

Folders should usually be 755 and files 644. Do not set 777; on a shared server that means writable by others.

4. Is the file really there?#

It seems obvious, but we have seen several times that the save path differed from the expected path:

Bash
ls -la ~/app/storage/app/public/

If you customised the disk in config/filesystems.php, the file may land somewhere else.

If your host does not allow creating symlinks, or you do not want to depend on them, you can deliver files through a Laravel route:

PHP
Route::get('/media/{path}', function (string $path) {
    abort_unless(Storage::disk('public')->exists($path), 404);

    return Storage::disk('public')->response($path);
})->where('path', '.*');

This works, but it has a cost: every image triggers a full Laravel boot, and you must handle browser caching and conditional headers yourself. For high-traffic images the first way is far better. Keep this way for files that really need a permission check.

A symlink points to an absolute path. When the host changes or the account username changes, that path no longer exists and the link points to nowhere.

The symptom is confusing: the files are on disk, the link shows in the listing, but all images get 404.

Find broken links with this command:

Bash
find ~/public_html -maxdepth 1 -xtype l

The -xtype l switch means "a symlink whose target does not exist" — exactly what you are looking for.

The cure is to recreate it, but first the old link has to be removed. Note that deleting the link itself does not delete the target files:

Bash
rm ~/public_html/storage
ln -s /home/NEWUSER/app/storage/app/public /home/NEWUSER/public_html/storage

Never add a trailing slash. The command rm ~/public_html/storage/ with a slash can behave differently and errors on some systems. Without the slash, only the link itself is removed.

Put this on your post-migration checklist, next to clearing the config cache and checking paths. Anything that depends on an absolute path is always the first casualty of a migration.

A simple test that takes ten seconds and clarifies the direction of debugging: put a plain text file in the target folder and request it directly.

Bash
echo ok > ~/app/storage/app/public/ping.txt
curl -s https://example.com/storage/ping.txt
  • If ok comes back, the link is healthy and the problem is elsewhere: the URL you generate in HTML, the file name, or the disk setting.
  • If you got a 404, the problem is in the link or the web server configuration, and you should solve it here.

Then delete the test file.

If the first test worked and images still do not load, look at the URL you build in the HTML output:

PHP
Storage::disk('public')->url($path);

This function builds the URL from the APP_URL value and the disk setting. If APP_URL is wrong — for example http instead of https, or an old domain — the URLs are generated wrong while everything else is healthy.

Short checklist#

  • Check the symlink function; if it is blocked, do not bother with artisan storage:link.
  • If the web root is public_html, create the link manually from the shell.
  • The target path must be storage/app/public, not storage.
  • After every host move or username change, recreate the link.
  • Do the final test with a real HTTP request, not by looking at the file system.

The rest of what you need to know about cPanel before deploying is collected in the shared hosting checklist for Laravel.