Laravel storage:link returns 403 Forbidden: fixes for OVH and Apache

php artisan storage:link creates the link, yet the files in storage/app/public return 403: the three causes (absolute link the server will not follow, FollowSymLinks disabled, permissions), the fixes in order, and the fallback route when symbolic links are forbidden.

Laravel storage:link: fix the 403 Forbidden error on OVH
Quick answer

On shared hosting, the link created by php artisan storage:link points to an absolute path the web server does not follow, hence the 403. Recreate it as a relative link: php artisan storage:link --relative, or over SSH from the public folder: ln -s ../storage/app/public storage. If symbolic links are forbidden, serve the files through a Laravel route.

You ran php artisan storage:link, the command reported that the link had been created, and yet every image in storage/app/public returns 403 Forbidden. Locally everything works; the problem shows up on shared hosting, OVH first among them. Here is what the command does, why the server refuses to serve the files, and the fixes in the order you should try them.

Laravel keeps user-uploaded files in storage/app/public, outside the web root, for security. To make them reachable it does not copy them: it creates a symbolic link public/storage pointing to storage/app/public. When the browser requests https://exemple.fr/storage/photo.jpg, Apache follows the link and serves storage/app/public/photo.jpg.

bash
php artisan storage:link
# The [public/storage] link has been connected to [storage/app/public].

ls -l public/
# storage -> /home/compte/www/mon-projet/storage/app/public

The detail that matters is on the last line: by default, the link is absolute. It contains the full path of the storage folder as PHP sees it when the command runs.

Why the server answers 403

Three causes, from the most common to the rarest. The message is the same in all three cases; it is the host’s configuration that tells them apart.

Cause 1: an absolute path the web server cannot see

On shared hosting, PHP and Apache do not always work from the same root path. The account is mounted under a path (for example /homez.123/compte/) that PHP wrote into the link, while Apache serves the site from an alias (/home/compte/) or from a sandboxed environment. The link points to a folder Apache cannot reach: it refuses, 403.

The fix is to create a relative link, which depends on no root path at all. Since Laravel 8, the command does it directly:

bash
rm public/storage                      # remove the old absolute link
php artisan storage:link --relative
ls -l public/
# storage -> ../storage/app/public

Without access to Artisan, the same thing over SSH, standing inside the public folder:

bash
cd public
rm -f storage
ln -s ../storage/app/public storage

The target ../storage/app/public is resolved relative to the location of the link, that is public/: it goes up one level, then down into storage/app/public. It stays valid whatever the mount path.

So that future deployments keep this choice, declare the link in the configuration rather than relying on the default value:

config/filesystems.php
'links' => [
    public_path('storage') => storage_path('app/public'),
],

And always call php artisan storage:link --relative in your deployment script, after composer install.

Apache only follows a link if the Options directive allows it, with FollowSymLinks or SymLinksIfOwnerMatch. The second value, common on shared hosting, additionally requires the link and its target to belong to the same user. If the option is missing, the request ends in a 403.

The public/.htaccess file shipped with Laravel does not enable FollowSymLinks: it stops at Options -MultiViews -Indexes and relies on the server configuration. Yet mod_rewrite requires the directory to allow following links anyway: if Laravel’s clean URLs work, FollowSymLinks or SymLinksIfOwnerMatch is active, and a 403 that persists then points to the latter, with a link that does not belong to the right user.

public/.htaccess
<IfModule mod_rewrite.c>
    <IfModule mod_negotiation.c>
        Options -MultiViews -Indexes
    </IfModule>

    RewriteEngine On
    …
</IfModule>

If the server does not follow links, or if you are not sure, add this explicitly at the top of the file:

public/.htaccess
Options +FollowSymLinks

Two possible outcomes: the 403 disappears, or the whole site turns into a 500 Internal Server Error. In the second case, the host forbids changing Options in a .htaccess (AllowOverride without Options): remove the line and try Options +SymLinksIfOwnerMatch, which is sometimes tolerated. If neither gets through, the solution is the fallback route further down.

Cause 3: insufficient permissions along the folder chain

To serve storage/app/public/photo.jpg, the Apache user must be able to traverse every folder on the path (execute permission) and read the file. A storage folder set to 700, created by another user or by a deployment run as root, is enough to trigger the 403.

bash
chmod 755 storage storage/app storage/app/public
chmod 644 storage/app/public/*.jpg   # or find … -type f -exec chmod 644 {} +
chown -R compte:compte storage        # the same user as the link, for SymLinksIfOwnerMatch

Do not apply 777: it is useless for reading, and dangerous on shared hosting.

Some hosts disable the PHP symlink() function (it is listed in disable_functions) and offer no SSH: the link cannot be created. Laravel can then serve the files itself, through a route that reads the public disk.

routes/web.php
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;

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

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

response() returns the file with the right MIME type and cache headers. The URL is still /storage/photo.jpg, so Storage::url() and all existing templates keep working. The cost: every image goes through PHP instead of being served directly by Apache. On a small site that is invisible; on a high-traffic site, prefer the symbolic link or external storage (S3 or equivalent).

Checking that everything is in place

  1. The link exists and points to the right place: ls -l public/ must show storage -> ../storage/app/public. A red link in a colour-coded terminal is a broken link.
  2. The file really is on the public disk: php artisan tinker, then Storage::disk('public')->exists('photo.jpg'). If it returns false, the file was saved to another disk (local writes to storage/app, not to storage/app/public).
  3. The generated URL is correct: Storage::url('photo.jpg') must give /storage/photo.jpg, and the full URL depends on APP_URL in .env.
  4. The server answers 200: curl -I https://exemple.fr/storage/photo.jpg. A 403 after the three previous checks points to cause 2.

Why it all worked locally

On your machine, PHP and the server see the same file system with the same user, and php artisan serve does not go through Apache: the absolute link works, FollowSymLinks never comes into play and the permissions are those of your session. Nothing flags the problem before the first deployment. Two habits avoid it: always --relative, and a deployment script that recreates the link on every release.

For a server you administer yourself, configuring Apache for Laravel is covered in Serving a Laravel application with Apache on Debian 13, a chapter of the series Installing Laravel 13 on a Debian 13 server.

Common errors

Creating the link from the wrong folder ln -s ../storage/app/public storage must be run from public/. From the project root, the relative target leads nowhere and Apache returns 404 or 403.
A stale link left behind storage:link refuses to overwrite an existing link (“The [public/storage] link already exists”). Delete it first (rm public/storage), or use --force.
Forgetting APP_URL Storage::url('photo.jpg') builds the URL from APP_URL in .env. A value left at http://localhost produces broken links in production even though the symbolic link is fine.
Deploying by copying files over FTP An FTP client does not transfer a symbolic link: it copies an empty folder, or nothing at all. The link must be created on the server, over SSH or by a script.

Laravel

Damien Flandrin Web developer since 2010, creator of Gekkode and Email Impact. Every article is tested on a real project before publication. Contact
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.