Laravel storage:link: risolvere il 403 Forbidden su OVH e Apache

php artisan storage:link crea il link e i file di storage/app/public rispondono 403: le tre cause (link assoluto che il server non segue, FollowSymLinks disattivato, permessi), le correzioni in ordine e la route di riserva quando i link simbolici sono vietati.

Errore 403 Forbidden con il comando storage:link di Laravel
Risposta rapida

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.

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.

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

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

bash
rm public/storage                      # eliminare il vecchio link assoluto
php artisan storage:link --relative
ls -l public/
# storage -> ../storage/app/public

Senza accesso ad Artisan, la stessa cosa in SSH, mettendosi dentro la cartella public:

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

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

config/filesystems.php
'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.

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.

public/.htaccess
<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:

public/.htaccess
Options +FollowSymLinks

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

bash
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 SymLinksIfOwnerMatch

Non applicare 777: è inutile per la lettura, e pericoloso su un hosting condiviso.

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.

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() 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

  1. Il link esiste e punta al posto giusto: ls -l public/ deve mostrare storage -> ../storage/app/public. Un link rosso in un terminale colorato è un link rotto.
  2. Il file è davvero nel disco public: php artisan tinker, poi Storage::disk('public')->exists('photo.jpg'). Se restituisce false, il file è stato salvato su un altro disco (local scrive in storage/app, non in storage/app/public).
  3. L’URL generato è corretto: Storage::url('photo.jpg') deve dare /storage/photo.jpg, e l’URL completo dipende da APP_URL in .env.
  4. 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

Creare il link dalla cartella sbagliata 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.
Un vecchio link rimasto lì storage:link si rifiuta di sovrascrivere un link esistente («The [public/storage] link already exists»). Eliminalo prima (rm public/storage), oppure usa --force.
Dimenticare APP_URL 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.
Distribuire copiando i file via FTP Un client FTP non trasferisce un link simbolico: copia una cartella vuota o niente. Il link va creato sul server, in SSH o con uno script.

Laravel

Damien Flandrin Sviluppatore web dal 2010, creatore di Gekkode e di Email Impact. Ogni articolo è testato su un progetto reale prima della pubblicazione. Contatti
Newsletter

I nuovi test, tutorial e progetti, via e-mail.

Test riproducibili, codice versionato, risultati datati. Mai spam.