
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.
Ce que fait php artisan storage:link
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.
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/publicLe 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 :
rm public/storage # supprimer l'ancien lien absolu
php artisan storage:link --relative
ls -l public/
# storage -> ../storage/app/publicSans accès à Artisan, la même chose en SSH, en se plaçant dans le dossier public :
cd public
rm -f storage
ln -s ../storage/app/public storageLa 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 :
'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.
<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 :
Options +FollowSymLinksDeux 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.
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 SymLinksIfOwnerMatchN’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.
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
- Le lien existe et pointe au bon endroit :
ls -l public/doit afficherstorage -> ../storage/app/public. Un lien rouge dans un terminal coloré est un lien cassé. - Le fichier est bien dans le disque public :
php artisan tinker, puisStorage::disk('public')->exists('photo.jpg'). S’il renvoiefalse, le fichier a été enregistré sur un autre disque (localécrit dansstorage/app, pas dansstorage/app/public). - L’URL générée est correcte :
Storage::url('photo.jpg')doit donner/storage/photo.jpg, et l’URL complète dépend deAPP_URLdans.env. - 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
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.storage:link refuse d’écraser un lien existant (« The [public/storage] link already exists »). Supprimez-le d’abord (rm public/storage), ou utilisez --force.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.

