# Why storage:link Breaks on cPanel and How to Fix It

- آدرس: https://di9.ir/blog/en/laravel-storage-link-cpanel-fix
- نویسنده: danidiba
- انتشار: 2026-08-08T00:09:12+03:30
- به‌روزرسانی: 2026-08-08T00:09:12+03:30
- دسته: Laravel
- برچسب‌ها: سی‌پنل، لاراول، هاست اشتراکی

> On cPanel hosting, storage:link either fails or creates the link in the wrong place, because Laravel treats public as the web root while cPanel uses public_html. Here is the manual fix, and why only one folder should be linked.

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.

## Problem one: the symlink function is blocked

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](https://di9.ir/blog/disable-functions-%D9%87%D8%A7%D8%B3%D8%AA-%D8%A7%D8%B4%D8%AA%D8%B1%D8%A7%DA%A9%DB%8C).

## 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:

```
/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:

```
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
```

## Security note: link only one folder

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:

```
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.

## If the link is right and you still get 404

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.

### 1. Does the web server follow symlinks?

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.

## The second way: no symlink

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.

## After a host move, the link breaks

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.

## How to tell whether the problem is the link or the code

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](https://di9.ir/blog/%DA%86%DA%A9-%D9%84%DB%8C%D8%B3%D8%AA-%D9%87%D8%A7%D8%B3%D8%AA-%D8%A7%D8%B4%D8%AA%D8%B1%D8%A7%DA%A9%DB%8C-%D8%A8%D8%B1%D8%A7%DB%8C-%D9%84%D8%A7%D8%B1%D8%A7%D9%88%D9%84).

## پرسش‌های متداول

### Why does storage:link fail on shared hosting?

There are two common causes. Either the symlink function is blocked in disable_functions, or the link is created but lands inside the project's public folder, which on cPanel is not the web root and is not visible from the internet.

### How do I create the symlink manually?

From the terminal or SSH, use the ln command with the s switch, from the project's storage/app/public to a name inside public_html. Because ln is a system binary, the disable_functions restriction does not affect it.

### Is linking the whole storage folder dangerous?

Yes. Only storage/app/public should be linked. If you link the whole storage folder, logs, cache files and any other private file become downloadable from the internet.

### Do I need to recreate the link after moving hosts?

Yes. A symlink points to an absolute path, and the username and home path change after a move. After every migration, check the link and recreate it if needed.
