
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.
Co robi php artisan storage:link
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.
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/publicSzczegół, 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:
rm public/storage # usuwa stary link bezwzględny
php artisan storage:link --relative
ls -l public/
# storage -> ../storage/app/publicBez dostępu do Artisana to samo przez SSH, stojąc w katalogu public:
cd public
rm -f storage
ln -s ../storage/app/public storageCel ../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ą:
'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.
<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:
Options +FollowSymLinksMoż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.
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 SymLinksIfOwnerMatchNie 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.
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
- 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. - Plik faktycznie leży na dysku public:
php artisan tinker, a potemStorage::disk('public')->exists('photo.jpg'). Jeśli zwracafalse, plik został zapisany na innym dysku (localpisze dostorage/app, a nie dostorage/app/public). - Wygenerowany adres jest poprawny:
Storage::url('photo.jpg')musi dać/storage/photo.jpg, a pełny adres zależy odAPP_URLw.env. - 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
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.storage:link odmawia nadpisania istniejącego linku („The [public/storage] link already exists”). Usuń go najpierw (rm public/storage) albo użyj --force.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.

