Laravel storage:link : corriger l’erreur 403 Forbidden (OVH, hébergement mutualisé, Apache)

php artisan storage:link crée le lien, et les fichiers de storage/app/public répondent 403 : les trois causes (lien absolu que le serveur ne suit pas, FollowSymLinks désactivé, droits), les corrections dans l’ordre, et la route de secours quand les liens symboliques sont interdits.

Error 403 Forbidden avec la commande storage:link
Réponse rapide

Sur un hébergement mutualisé, le lien créé par php artisan storage:link pointe vers un chemin absolu que le serveur web ne suit pas, d’où le 403. Recréez-le en relatif : php artisan storage:link --relative, ou en SSH depuis le dossier public : ln -s ../storage/app/public storage. Si les liens symboliques sont interdits, servez les fichiers par une route Laravel.

Vous avez lancé php artisan storage:link, la commande a répondu que le lien était créé, et pourtant chaque image de storage/app/public renvoie 403 Forbidden. En local tout fonctionne ; le problème apparaît sur un hébergement mutualisé, OVH en tête. Voici ce que fait la commande, pourquoi le serveur refuse de servir les fichiers, et les corrections dans l’ordre où il faut les tenter.

Laravel range les fichiers envoyés par les utilisateurs dans storage/app/public, hors de la racine web, par sécurité. Pour les rendre accessibles, il ne les copie pas : il crée un lien symbolique public/storage qui pointe vers storage/app/public. Quand le navigateur demande https://exemple.fr/storage/photo.jpg, Apache suit le lien et sert 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

Le détail qui compte est sur la dernière ligne : par défaut, le lien est absolu. Il contient le chemin complet du dossier storage tel que PHP le voit au moment de la commande.

Pourquoi le serveur répond 403

Trois causes, de la plus fréquente à la plus rare. Le message est le même dans les trois cas ; c’est la configuration de l’hébergeur qui les distingue.

Cause 1 : un chemin absolu que le serveur web ne voit pas

Sur un mutualisé, PHP et Apache ne travaillent pas toujours avec le même chemin racine. Le compte est monté sous un chemin (par exemple /homez.123/compte/) que PHP a inscrit dans le lien, tandis qu’Apache sert le site depuis un alias (/home/compte/) ou depuis un environnement cloisonné. Le lien pointe vers un dossier qu’Apache ne peut pas atteindre : il refuse, 403.

La correction est de créer un lien relatif, qui ne dépend d’aucun chemin racine. Depuis Laravel 8, la commande le fait directement :

bash
rm public/storage                      # supprimer l'ancien lien absolu
php artisan storage:link --relative
ls -l public/
# storage -> ../storage/app/public

Sans accès à Artisan, la même chose en SSH, en se plaçant dans le dossier public :

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

La cible ../storage/app/public est résolue par rapport à l’emplacement du lien, c’est-à-dire public/ : elle remonte d’un niveau puis descend dans storage/app/public. Elle reste vraie quel que soit le chemin de montage.

Pour que les déploiements futurs conservent ce choix, déclarez le lien dans la configuration plutôt que de compter sur la valeur par défaut :

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

Et appelez toujours php artisan storage:link --relative dans votre script de déploiement, après le composer install.

Cause 2 : Apache n’a pas le droit de suivre les liens symboliques

Apache ne suit un lien que si la directive Options l’autorise, avec FollowSymLinks ou SymLinksIfOwnerMatch. Cette seconde valeur, fréquente sur les mutualisés, exige en plus que le lien et sa cible appartiennent au même utilisateur. Si l’option est absente, la requête aboutit à un 403.

Le fichier public/.htaccess fourni par Laravel n’active pas FollowSymLinks : il se limite à Options -MultiViews -Indexes et s’en remet à la configuration du serveur. Or mod_rewrite exige de toute façon que le dossier autorise le suivi des liens : si les URL propres de Laravel fonctionnent, FollowSymLinks ou SymLinksIfOwnerMatch est actif, et un 403 qui persiste désigne alors la seconde, avec un lien qui n’appartient pas au bon utilisateur.

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

    RewriteEngine On
    …
</IfModule>

Si le serveur ne suit pas les liens, ou si vous n’en êtes pas sûr, ajoutez explicitement, en tête du fichier :

public/.htaccess
Options +FollowSymLinks

Deux résultats possibles : le 403 disparaît, ou le site entier passe en 500 Internal Server Error. Dans ce second cas, l’hébergeur interdit de changer Options dans un .htaccess (AllowOverride sans Options) : retirez la ligne et essayez Options +SymLinksIfOwnerMatch, qui est parfois toléré. Si ni l’un ni l’autre ne passe, la solution est la route de secours plus bas.

Cause 3 : des droits insuffisants sur la chaîne de dossiers

Pour servir storage/app/public/photo.jpg, l’utilisateur Apache doit pouvoir traverser chaque dossier du chemin (droit d’exécution) et lire le fichier. Un dossier storage en 700, créé par un autre utilisateur ou par un déploiement en root, suffit à provoquer le 403.

bash
chmod 755 storage storage/app storage/app/public
chmod 644 storage/app/public/*.jpg   # ou find … -type f -exec chmod 644 {} +
chown -R compte:compte storage        # le même utilisateur que le lien, pour SymLinksIfOwnerMatch

N’appliquez pas 777 : c’est inutile pour la lecture, et dangereux sur un mutualisé.

La route de secours : servir les fichiers sans lien symbolique

Certains hébergements désactivent la fonction PHP symlink() (elle figure dans disable_functions) et n’offrent pas de SSH : impossible de créer le lien. Laravel peut alors servir les fichiers lui-même, par une route qui lit le disque public.

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() renvoie le fichier avec le bon type MIME et les en-têtes de cache. L’URL reste /storage/photo.jpg, donc Storage::url() et tous les gabarits existants continuent de fonctionner. Le coût : chaque image passe par PHP au lieu d’être servie directement par Apache. Sur un petit site c’est invisible ; sur un site à fort trafic, préférez le lien symbolique ou un stockage externe (S3 ou équivalent).

Vérifier que tout est en place

  1. Le lien existe et pointe au bon endroit : ls -l public/ doit afficher storage -> ../storage/app/public. Un lien rouge dans un terminal coloré est un lien cassé.
  2. Le fichier est bien dans le disque public : php artisan tinker, puis Storage::disk('public')->exists('photo.jpg'). S’il renvoie false, le fichier a été enregistré sur un autre disque (local écrit dans storage/app, pas dans storage/app/public).
  3. L’URL générée est correcte : Storage::url('photo.jpg') doit donner /storage/photo.jpg, et l’URL complète dépend de APP_URL dans .env.
  4. Le serveur répond 200 : curl -I https://exemple.fr/storage/photo.jpg. Un 403 après les trois vérifications précédentes désigne la cause 2.

Pourquoi tout marchait en local

Sur votre machine, PHP et le serveur voient le même système de fichiers avec le même utilisateur, et php artisan serve ne passe pas par Apache : le lien absolu fonctionne, FollowSymLinks n’entre pas en jeu et les droits sont ceux de votre session. Rien ne signale le problème avant le premier déploiement. Deux habitudes l’évitent : toujours --relative, et un script de déploiement qui recrée le lien à chaque mise en ligne.

Pour un serveur que vous administrez vous-même, la configuration d’Apache pour Laravel est détaillée dans Servir une application Laravel avec Apache sur Debian 13, chapitre de la série Installer Laravel 13 sur un serveur Debian 13.

Erreurs fréquentes

Créer le lien depuis le mauvais dossier ln -s ../storage/app/public storage doit être lancé depuis public/. Depuis la racine du projet, la cible relative ne mène nulle part et Apache renvoie 404 ou 403.
Un ancien lien qui traîne storage:link refuse d’écraser un lien existant (« The [public/storage] link already exists »). Supprimez-le d’abord (rm public/storage), ou utilisez --force.
Oublier APP_URL Storage::url('photo.jpg') construit l’URL à partir de APP_URL dans .env. Une valeur restée sur http://localhost donne des liens cassés en production alors que le lien symbolique est bon.
Déployer en copiant les fichiers par FTP Un client FTP ne transfère pas un lien symbolique : il copie un dossier vide ou rien. Le lien doit être créé sur le serveur, en SSH ou par un script.

Laravel

Damien Flandrin Développeur web depuis 2010, créateur de Gekkode et d’Email Impact. Chaque article est testé sur un projet réel avant publication. Contact
Newsletter

Les nouveaux tests, tutoriels et projets, par e-mail.

Tests reproductibles, code versionné, résultats datés. Jamais de spam.