
Bij shared hosting wijst de link die php artisan storage:link aanmaakt naar een absoluut pad dat de webserver niet volgt, vandaar de 403. Maak hem opnieuw aan als relatieve link: php artisan storage:link --relative, of via SSH vanuit de map public: ln -s ../storage/app/public storage. Zijn symbolische links verboden, serveer de bestanden dan via een Laravel-route.
Je hebt php artisan storage:link uitgevoerd, de opdracht meldde dat de link was aangemaakt, en toch geeft elke afbeelding in storage/app/public een 403 Forbidden terug. Lokaal werkt alles, het probleem duikt op bij shared hosting, met OVH voorop. Hier lees je wat de opdracht doet, waarom de server weigert de bestanden te serveren, en de oplossingen in de volgorde waarin je ze moet proberen.
Wat php artisan storage:link doet
Laravel bewaart bestanden die gebruikers uploaden in storage/app/public, buiten de webroot, om veiligheidsredenen. Om ze toegankelijk te maken, kopieert het ze niet: het maakt een symbolische link public/storage aan die verwijst naar storage/app/public. Wanneer de browser https://exemple.fr/storage/photo.jpg opvraagt, volgt Apache de link en serveert 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/publicHet detail dat ertoe doet staat op de laatste regel: standaard is de link absoluut. Ze bevat het volledige pad van de map storage zoals PHP dat ziet op het moment van de opdracht.
Waarom de server 403 teruggeeft
Drie oorzaken, van de meest voorkomende naar de zeldzaamste. De melding is in alle drie de gevallen hetzelfde, het is de configuratie van de hosting die het verschil maakt.
Oorzaak 1: een absoluut pad dat de webserver niet ziet
Bij shared hosting werken PHP en Apache niet altijd met hetzelfde rootpad. Het account is gekoppeld aan een pad (bijvoorbeeld /homez.123/compte/) dat PHP in de link heeft opgenomen, terwijl Apache de site serveert vanaf een alias (/home/compte/) of vanuit een afgeschermde omgeving. De link verwijst naar een map die Apache niet kan bereiken: hij weigert, 403.
De oplossing is een relatieve link aan te maken, die van geen enkel rootpad afhankelijk is. Sinds Laravel 8 doet de opdracht dat rechtstreeks:
rm public/storage # de oude absolute link verwijderen
php artisan storage:link --relative
ls -l public/
# storage -> ../storage/app/publicZonder toegang tot Artisan hetzelfde via SSH, terwijl je je in de map public bevindt:
cd public
rm -f storage
ln -s ../storage/app/public storageHet doel ../storage/app/public wordt herleid ten opzichte van de plaats van de link, dus public/: het gaat één niveau omhoog en daarna weer omlaag naar storage/app/public. Dat blijft kloppen ongeacht het koppelpad.
Zodat toekomstige deployments deze keuze behouden, declareer je de link in de configuratie in plaats van te vertrouwen op de standaardwaarde:
'links' => [
public_path('storage') => storage_path('app/public'),
],En roep in je deploymentscript altijd php artisan storage:link --relative aan, na de composer install.
Oorzaak 2: Apache mag symbolische links niet volgen
Apache volgt een link alleen als de directive Options dat toestaat, met FollowSymLinks of SymLinksIfOwnerMatch. Die tweede waarde, veel voorkomend bij shared hosting, vereist bovendien dat de link en zijn doel dezelfde eigenaar hebben. Ontbreekt de optie, dan resulteert het verzoek in een 403.
Het bestand public/.htaccess dat Laravel meelevert, activeert FollowSymLinks niet: het beperkt zich tot Options -MultiViews -Indexes en vertrouwt op de serverconfiguratie. Maar mod_rewrite vereist sowieso dat de map het volgen van links toestaat: werken de nette URL’s van Laravel, dan staat FollowSymLinks of SymLinksIfOwnerMatch aan, en wijst een aanhoudende 403 dan op die tweede, met een link die niet aan de juiste gebruiker toebehoort.
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
…
</IfModule>Als de server de links niet volgt, of als je het niet zeker weet, voeg dan expliciet toe, bovenaan het bestand:
Options +FollowSymLinksTwee mogelijke uitkomsten: de 403 verdwijnt, of de hele site valt terug op 500 Internal Server Error. In dat tweede geval verbiedt de hosting om Options in een .htaccess te wijzigen (AllowOverride zonder Options): verwijder de regel en probeer Options +SymLinksIfOwnerMatch, wat soms wel wordt toegestaan. Werkt geen van beide, dan is de oplossing de noodroute verderop.
Oorzaak 3: onvoldoende rechten op de mappenketen
Om storage/app/public/photo.jpg te serveren, moet de Apache-gebruiker elke map in het pad kunnen doorlopen (uitvoerrecht) en het bestand kunnen lezen. Een map storage met rechten 700, aangemaakt door een andere gebruiker of door een deployment als root, volstaat al om de 403 te veroorzaken.
chmod 755 storage storage/app storage/app/public
chmod 644 storage/app/public/*.jpg # of find … -type f -exec chmod 644 {} +
chown -R compte:compte storage # dezelfde gebruiker als de link, voor SymLinksIfOwnerMatchGebruik geen 777: dat is niet nodig om te lezen, en gevaarlijk bij shared hosting.
De noodroute: bestanden serveren zonder symbolische link
Sommige hostings schakelen de PHP-functie symlink() uit (ze staat in disable_functions) en bieden geen SSH aan: dan kun je de link niet aanmaken. Laravel kan de bestanden dan zelf serveren, via een route die de disk public uitleest.
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() geeft het bestand terug met het juiste MIME-type en de juiste cache-headers. De URL blijft /storage/photo.jpg, dus Storage::url() en alle bestaande templates blijven werken. De kostprijs: elke afbeelding gaat via PHP in plaats van rechtstreeks door Apache te worden geserveerd. Op een kleine site merk je daar niets van, op een site met veel verkeer geef je de voorkeur aan de symbolische link of aan externe opslag (S3 of gelijkwaardig).
Controleren of alles klopt
- De link bestaat en wijst naar de juiste plek:
ls -l public/moetstorage -> ../storage/app/publictonen. Een rode link in een gekleurde terminal is een kapotte link. - Het bestand staat wel degelijk op de disk public:
php artisan tinker, daarnaStorage::disk('public')->exists('photo.jpg'). Geeft ditfalseterug, dan is het bestand op een andere disk opgeslagen (localschrijft naarstorage/app, niet naarstorage/app/public). - De gegenereerde URL klopt:
Storage::url('photo.jpg')moet/storage/photo.jpgopleveren, en de volledige URL hangt af vanAPP_URLin.env. - De server antwoordt met 200:
curl -I https://exemple.fr/storage/photo.jpg. Een 403 na de drie voorgaande controles wijst op oorzaak 2.
Waarom lokaal alles werkte
Op je eigen machine zien PHP en de server hetzelfde bestandssysteem met dezelfde gebruiker, en php artisan serve gaat niet via Apache: de absolute link werkt, FollowSymLinks speelt geen rol en de rechten zijn die van je eigen sessie. Niets wijst op het probleem vóór de eerste deployment. Twee gewoontes voorkomen het: altijd --relative, en een deploymentscript dat de link bij elke release opnieuw aanmaakt.
Beheer je zelf een server, dan staat de Apache-configuratie voor Laravel uitgebreid beschreven in Een Laravel-applicatie serveren met Apache op Debian 13, een hoofdstuk uit de reeks Laravel 13 installeren op een Debian 13-server.
Veelgemaakte fouten
ln -s ../storage/app/public storage moet je vanuit public/ uitvoeren. Vanuit de root van het project leidt het relatieve doel nergens naartoe en geeft Apache 404 of 403 terug.storage:link weigert een bestaande link te overschrijven (“The [public/storage] link already exists”). Verwijder hem eerst (rm public/storage), of gebruik --force.Storage::url('photo.jpg') bouwt de URL op basis van APP_URL in .env. Een waarde die op http://localhost is blijven staan, geeft kapotte links in productie terwijl de symbolische link zelf goed is.

