
Su un hosting condiviso il link creato da php artisan storage:link punta a un percorso assoluto che il server web non segue, da cui il 403. Ricrealo in forma relativa: php artisan storage:link --relative, oppure in SSH dalla cartella public: ln -s ../storage/app/public storage. Se i link simbolici sono vietati, servi i file con una route Laravel.
Hai lanciato php artisan storage:link, il comando ha risposto che il link era creato, eppure ogni immagine di storage/app/public restituisce 403 Forbidden. In locale funziona tutto; il problema compare su un hosting condiviso, OVH in testa. Ecco che cosa fa il comando, perché il server si rifiuta di servire i file e le correzioni nell’ordine in cui vanno tentate.
Che cosa fa php artisan storage:link
Laravel mette i file inviati dagli utenti in storage/app/public, fuori dalla radice web, per sicurezza. Per renderli accessibili non li copia: crea un link simbolico public/storage che punta a storage/app/public. Quando il browser chiede https://exemple.fr/storage/photo.jpg, Apache segue il link e serve 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/publicIl dettaglio che conta è nell’ultima riga: per impostazione predefinita il link è assoluto. Contiene il percorso completo della cartella storage così come PHP la vede al momento del comando.
Perché il server risponde 403
Tre cause, dalla più frequente alla più rara. Il messaggio è lo stesso in tutti e tre i casi; è la configurazione dell’hosting a distinguerle.
Causa 1: un percorso assoluto che il server web non vede
Su un hosting condiviso, PHP e Apache non lavorano sempre con la stessa radice. L’account è montato sotto un percorso (per esempio /homez.123/compte/) che PHP ha scritto nel link, mentre Apache serve il sito da un alias (/home/compte/) o da un ambiente isolato. Il link punta a una cartella che Apache non può raggiungere: rifiuta, 403.
La correzione è creare un link relativo, che non dipende da nessuna radice. Da Laravel 8 il comando lo fa direttamente:
rm public/storage # eliminare il vecchio link assoluto
php artisan storage:link --relative
ls -l public/
# storage -> ../storage/app/publicSenza accesso ad Artisan, la stessa cosa in SSH, mettendosi dentro la cartella public:
cd public
rm -f storage
ln -s ../storage/app/public storageLa destinazione ../storage/app/public viene risolta rispetto alla posizione del link, cioè public/: risale di un livello, poi scende in storage/app/public. Resta valida qualunque sia il percorso di montaggio.
Perché i deploy successivi conservino questa scelta, dichiara il link nella configurazione invece di affidarti al valore predefinito:
'links' => [
public_path('storage') => storage_path('app/public'),
],E chiama sempre php artisan storage:link --relative nel tuo script di deploy, dopo il composer install.
Causa 2: Apache non ha il diritto di seguire i link simbolici
Apache segue un link solo se la direttiva Options lo autorizza, con FollowSymLinks o SymLinksIfOwnerMatch. Questo secondo valore, frequente sugli hosting condivisi, richiede in più che il link e la sua destinazione appartengano allo stesso utente. Se l’opzione manca, la richiesta finisce in un 403.
Il file public/.htaccess fornito da Laravel non attiva FollowSymLinks: si limita a Options -MultiViews -Indexes e si affida alla configurazione del server. Del resto mod_rewrite richiede comunque che la cartella autorizzi il seguito dei link: se gli URL puliti di Laravel funzionano, FollowSymLinks o SymLinksIfOwnerMatch è attivo, e un 403 che resiste indica allora la seconda direttiva, con un link che non appartiene all’utente giusto.
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
…
</IfModule>Se il server non segue i link, o se non ne sei sicuro, aggiungi esplicitamente, in cima al file:
Options +FollowSymLinksDue risultati possibili: il 403 sparisce, oppure l’intero sito passa a 500 Internal Server Error. In questo secondo caso l’hosting vieta di cambiare Options in un .htaccess (AllowOverride senza Options): togli la riga e prova Options +SymLinksIfOwnerMatch, che a volte è tollerato. Se non passa né l’una né l’altra, la soluzione è la route di riserva più in basso.
Causa 3: permessi insufficienti sulla catena di cartelle
Per servire storage/app/public/photo.jpg, l’utente di Apache deve poter attraversare ogni cartella del percorso (diritto di esecuzione) e leggere il file. Una cartella storage in 700, creata da un altro utente o da un deploy fatto come root, basta a provocare il 403.
chmod 755 storage storage/app storage/app/public
chmod 644 storage/app/public/*.jpg # oppure find … -type f -exec chmod 644 {} +
chown -R compte:compte storage # lo stesso utente del link, per SymLinksIfOwnerMatchNon applicare 777: è inutile per la lettura, e pericoloso su un hosting condiviso.
La route di riserva: servire i file senza link simbolico
Certi hosting disattivano la funzione PHP symlink() (compare in disable_functions) e non offrono SSH: impossibile creare il link. Laravel può allora servire i file da solo, con una route che legge il disco 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() restituisce il file con il tipo MIME giusto e gli header di cache. L’URL resta /storage/photo.jpg, quindi Storage::url() e tutti i template esistenti continuano a funzionare. Il costo: ogni immagine passa da PHP invece di essere servita direttamente da Apache. Su un sito piccolo è invisibile; su un sito ad alto traffico, preferisci il link simbolico o uno storage esterno (S3 o equivalente).
Verificare che sia tutto a posto
- Il link esiste e punta al posto giusto:
ls -l public/deve mostrarestorage -> ../storage/app/public. Un link rosso in un terminale colorato è un link rotto. - Il file è davvero nel disco public:
php artisan tinker, poiStorage::disk('public')->exists('photo.jpg'). Se restituiscefalse, il file è stato salvato su un altro disco (localscrive instorage/app, non instorage/app/public). - L’URL generato è corretto:
Storage::url('photo.jpg')deve dare/storage/photo.jpg, e l’URL completo dipende daAPP_URLin.env. - Il server risponde 200:
curl -I https://exemple.fr/storage/photo.jpg. Un 403 dopo le tre verifiche precedenti indica la causa 2.
Perché in locale funzionava tutto
Sulla tua macchina PHP e il server vedono lo stesso file system con lo stesso utente, e php artisan serve non passa da Apache: il link assoluto funziona, FollowSymLinks non entra in gioco e i permessi sono quelli della tua sessione. Niente segnala il problema prima del primo deploy. Due abitudini lo evitano: sempre --relative, e uno script di deploy che ricrea il link a ogni messa online.
Per un server che amministri tu, la configurazione di Apache per Laravel è descritta in Servire un’applicazione Laravel con Apache su Debian 13, capitolo della serie Installare Laravel 13 su un server Debian 13.
Errori frequenti
ln -s ../storage/app/public storage va lanciato da public/. Dalla radice del progetto la destinazione relativa non porta da nessuna parte e Apache risponde 404 o 403.storage:link si rifiuta di sovrascrivere un link esistente («The [public/storage] link already exists»). Eliminalo prima (rm public/storage), oppure usa --force.Storage::url('photo.jpg') costruisce l'URL a partire da APP_URL in .env. Un valore rimasto su http://localhost dà link rotti in produzione anche quando il link simbolico è corretto.

