Laravel storage:link i błąd 403 Forbidden na hostingu współdzielonym

php artisan storage:link tworzy link, a pliki z storage/app/public zwracają 403: trzy przyczyny (bezwzględny link, za którym serwer nie idzie, wyłączony FollowSymLinks, uprawnienia), poprawki po kolei i trasa awaryjna, gdy linki symboliczne są zabronione.

Błąd 403 Forbidden przy komendzie storage:link w Laravelu
Szybka odpowiedź

Na hostingu współdzielonym link utworzony przez php artisan storage:link wskazuje na ścieżkę bezwzględną, za którą serwer WWW nie idzie, i stąd błąd 403. Utwórz go ponownie jako względny: php artisan storage:link --relative, albo przez SSH z katalogu public: ln -s ../storage/app/public storage. Jeśli linki symboliczne są zabronione, wydawaj pliki trasą Laravela.

Uruchomiłeś php artisan storage:link, polecenie odpowiedziało, że link został utworzony, a mimo to każdy obrazek z storage/app/public zwraca 403 Forbidden. Lokalnie wszystko działa; problem pojawia się na hostingu współdzielonym, z OVH na czele. Oto co robi to polecenie, dlaczego serwer odmawia wydania plików i jakie poprawki stosować, w kolejności.

Laravel trzyma pliki wysłane przez użytkowników w storage/app/public, poza katalogiem głównym serwera WWW, ze względów bezpieczeństwa. Żeby je udostępnić, nie kopiuje ich: tworzy link symboliczny public/storage, który wskazuje na storage/app/public. Gdy przeglądarka prosi o https://exemple.fr/storage/photo.jpg, Apache idzie za linkiem i wydaje 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

Szczegół, który ma znaczenie, jest w ostatniej linii: domyślnie link jest bezwzględny. Zawiera pełną ścieżkę katalogu storage taką, jaką PHP widzi w chwili wywołania polecenia.

Dlaczego serwer odpowiada 403

Trzy przyczyny, od najczęstszej do najrzadszej. Komunikat jest w każdej z nich taki sam; różni je konfiguracja hostingu.

Przyczyna 1: ścieżka bezwzględna, której serwer WWW nie widzi

Na hostingu współdzielonym PHP i Apache nie zawsze pracują na tej samej ścieżce bazowej. Konto jest zamontowane pod jedną ścieżką (na przykład /homez.123/compte/), którą PHP wpisało do linku, a Apache wydaje witrynę spod aliasu (/home/compte/) albo z odizolowanego środowiska. Link wskazuje wtedy na katalog, do którego Apache nie może dojść: odmawia i zwraca 403.

Poprawka polega na utworzeniu linku względnego, który nie zależy od żadnej ścieżki bazowej. Od Laravela 8 robi to samo polecenie:

bash
rm public/storage                      # usuwa stary link bezwzględny
php artisan storage:link --relative
ls -l public/
# storage -> ../storage/app/public

Bez dostępu do Artisana to samo przez SSH, stojąc w katalogu public:

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

Cel ../storage/app/public jest rozwiązywany względem położenia linku, czyli public/: wychodzi o poziom wyżej, a potem schodzi do storage/app/public. Pozostaje prawdziwy niezależnie od ścieżki montowania.

Żeby kolejne wdrożenia zachowały ten wybór, zadeklaruj link w konfiguracji, zamiast liczyć na wartość domyślną:

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

I zawsze wywołuj php artisan storage:link --relative w skrypcie wdrożeniowym, po composer install.

Przyczyna 2: Apache nie ma prawa iść za linkami symbolicznymi

Apache podąża za linkiem tylko wtedy, gdy pozwala na to dyrektywa Options, z FollowSymLinks albo SymLinksIfOwnerMatch. Ta druga wartość, częsta na hostingach współdzielonych, wymaga dodatkowo, żeby link i jego cel należały do tego samego użytkownika. Gdy opcji brakuje, żądanie kończy się na 403.

Plik public/.htaccess dostarczany przez Laravela nie włącza FollowSymLinks: ogranicza się do Options -MultiViews -Indexes i zdaje się na konfigurację serwera. Tyle że mod_rewrite i tak wymaga, aby katalog zezwalał na podążanie za linkami: jeśli czyste adresy Laravela działają, to FollowSymLinks albo SymLinksIfOwnerMatch jest aktywne, a utrzymujące się 403 wskazuje wtedy na tę drugą dyrektywę, z linkiem, który nie należy do właściwego użytkownika.

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

    RewriteEngine On
    …
</IfModule>

Jeśli serwer nie podąża za linkami albo nie masz pewności, dopisz jawnie, na początku pliku:

public/.htaccess
Options +FollowSymLinks

Możliwe są dwa wyniki: 403 znika albo cała witryna przechodzi w 500 Internal Server Error. W tym drugim przypadku hosting zabrania zmieniać Options w pliku .htaccess (AllowOverride bez Options): usuń tę linię i spróbuj Options +SymLinksIfOwnerMatch, które bywa tolerowane. Jeśli ani jedno, ani drugie nie przechodzi, rozwiązaniem jest trasa awaryjna opisana niżej.

Przyczyna 3: za małe uprawnienia w łańcuchu katalogów

Żeby wydać storage/app/public/photo.jpg, użytkownik Apache musi móc przejść przez każdy katalog na ścieżce (prawo wykonywania) i odczytać plik. Katalog storage z prawami 700, utworzony przez innego użytkownika albo przez wdrożenie z konta root, wystarczy, żeby wywołać 403.

bash
chmod 755 storage storage/app storage/app/public
chmod 644 storage/app/public/*.jpg   # albo find … -type f -exec chmod 644 {} +
chown -R compte:compte storage        # ten sam użytkownik co link, dla SymLinksIfOwnerMatch

Nie ustawiaj 777: do odczytu jest zbędne, a na hostingu współdzielonym niebezpieczne.

Trasa awaryjna: wydawanie plików bez linku symbolicznego

Część hostingów wyłącza funkcję PHP symlink() (widnieje ona w disable_functions) i nie daje dostępu przez SSH: linku nie da się utworzyć. Laravel może wtedy wydawać pliki sam, przez trasę, która czyta dysk 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() zwraca plik z właściwym typem MIME i nagłówkami cache’u. Adres pozostaje /storage/photo.jpg, więc Storage::url() i wszystkie istniejące szablony działają dalej. Koszt: każdy obrazek przechodzi przez PHP, zamiast być wydany wprost przez Apache. Na małej witrynie tego nie widać; przy dużym ruchu wybierz link symboliczny albo magazyn zewnętrzny (S3 lub odpowiednik).

Sprawdź, czy wszystko jest na miejscu

  1. Link istnieje i wskazuje właściwe miejsce: ls -l public/ musi pokazać storage -> ../storage/app/public. Czerwony link w kolorowym terminalu to link zepsuty.
  2. Plik faktycznie leży na dysku public: php artisan tinker, a potem Storage::disk('public')->exists('photo.jpg'). Jeśli zwraca false, plik został zapisany na innym dysku (local pisze do storage/app, a nie do storage/app/public).
  3. Wygenerowany adres jest poprawny: Storage::url('photo.jpg') musi dać /storage/photo.jpg, a pełny adres zależy od APP_URL w .env.
  4. Serwer odpowiada 200: curl -I https://exemple.fr/storage/photo.jpg. Błąd 403 po trzech powyższych kontrolach wskazuje na przyczynę 2.

Dlaczego lokalnie wszystko działało

Na twojej maszynie PHP i serwer widzą ten sam system plików i tego samego użytkownika, a php artisan serve nie przechodzi przez Apache: link bezwzględny działa, FollowSymLinks nie wchodzi w grę, a uprawnienia są uprawnieniami twojej sesji. Nic nie zapowiada problemu przed pierwszym wdrożeniem. Zapobiegają mu dwa nawyki: zawsze --relative oraz skrypt wdrożeniowy, który odtwarza link przy każdej publikacji.

Konfigurację Apache pod Laravela na serwerze, którym zarządzasz samodzielnie, opisuje Laravel z Apache na Debianie 13, rozdział serii Instalacja Laravela 13 na serwerze Debian 13.

Częste błędy

Tworzenie linku z niewłaściwego katalogu ln -s ../storage/app/public storage trzeba uruchomić z public/. Z katalogu głównego projektu cel względny nie prowadzi nigdzie i Apache zwraca 404 albo 403.
Stary link, który został storage:link odmawia nadpisania istniejącego linku („The [public/storage] link already exists”). Usuń go najpierw (rm public/storage) albo użyj --force.
Zapomniany APP_URL Storage::url('photo.jpg') buduje adres na podstawie APP_URL z pliku .env. Wartość pozostawiona na http://localhost daje zepsute odnośniki na produkcji, choć sam link symboliczny jest dobry.
Wdrażanie przez kopiowanie plików po FTP Klient FTP nie przenosi linku symbolicznego: kopiuje pusty katalog albo nic. Link musi powstać na serwerze, przez SSH albo skryptem.

Laravel

Damien Flandrin Web developer od 2010 roku, twórca Gekkode i Email Impact. Każdy artykuł jest sprawdzany na prawdziwym projekcie przed publikacją. Kontakt
Newsletter

Nowe testy, poradniki i projekty — e-mailem.

Powtarzalne testy, wersjonowany kod, datowane wyniki. Nigdy spamu.