Laravel storage:link – 403 Forbidden auf Shared Hosting beheben

php artisan storage:link legt den Link an, und die Dateien aus storage/app/public antworten mit 403: die drei Ursachen (absoluter Link, dem der Server nicht folgt, deaktiviertes FollowSymLinks, Rechte), die Korrekturen der Reihe nach und die Fallback-Route, wenn Symlinks verboten sind.

Error 403 Forbidden bei storage:link in Laravel beheben
Schnelle Antwort

Auf einem Shared Hosting zeigt der von php artisan storage:link erzeugte Link auf einen absoluten Pfad, dem der Webserver nicht folgt – daher der 403. Lege ihn relativ neu an: php artisan storage:link --relative, oder per SSH aus dem Ordner public heraus: ln -s ../storage/app/public storage. Sind Symlinks verboten, lieferst du die Dateien über eine Laravel-Route aus.

Du hast php artisan storage:link ausgeführt, der Befehl hat gemeldet, dass der Link angelegt wurde, und trotzdem antwortet jedes Bild aus storage/app/public mit 403 Forbidden. Lokal läuft alles; das Problem taucht auf Shared Hosting auf, allen voran bei OVH. Hier steht, was der Befehl tut, warum der Server die Dateien verweigert, und in welcher Reihenfolge du die Korrekturen versuchst.

Laravel legt die von Nutzern hochgeladenen Dateien aus Sicherheitsgründen in storage/app/public ab, außerhalb des Web-Roots. Um sie erreichbar zu machen, kopiert es sie nicht: Es legt einen Symlink public/storage an, der auf storage/app/public zeigt. Fragt der Browser https://exemple.fr/storage/photo.jpg an, folgt Apache dem Link und liefert storage/app/public/photo.jpg aus.

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

Das entscheidende Detail steht in der letzten Zeile: Standardmäßig ist der Link absolut. Er enthält den vollständigen Pfad des Ordners storage, so wie PHP ihn zum Zeitpunkt des Befehls sieht.

Warum der Server mit 403 antwortet

Drei Ursachen, von der häufigsten zur seltensten. Die Meldung ist in allen drei Fällen dieselbe; unterschieden werden sie durch die Konfiguration des Hosters.

Ursache 1: ein absoluter Pfad, den der Webserver nicht sieht

Auf einem Shared Hosting arbeiten PHP und Apache nicht immer mit demselben Wurzelpfad. Das Konto ist unter einem Pfad eingehängt (zum Beispiel /homez.123/compte/), den PHP in den Link geschrieben hat, während Apache die Website über einen Alias (/home/compte/) oder aus einer abgeschotteten Umgebung ausliefert. Der Link zeigt auf einen Ordner, den Apache nicht erreichen kann: Er verweigert ihn, 403.

Die Korrektur besteht darin, einen relativen Link anzulegen, der von keinem Wurzelpfad abhängt. Seit Laravel 8 erledigt der Befehl das direkt:

bash
rm public/storage                      # den alten absoluten Link löschen
php artisan storage:link --relative
ls -l public/
# storage -> ../storage/app/public

Ohne Zugriff auf Artisan geht dasselbe per SSH, im Ordner public stehend:

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

Das Ziel ../storage/app/public wird relativ zum Ort des Links aufgelöst, also zu public/: Es geht eine Ebene hoch und dann hinunter nach storage/app/public. Es bleibt richtig, egal unter welchem Pfad das Konto eingehängt ist.

Damit künftige Deployments diese Wahl behalten, trage den Link in der Konfiguration ein, statt dich auf den Standardwert zu verlassen:

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

Und rufe in deinem Deploy-Skript immer php artisan storage:link --relative auf, nach dem composer install.

Apache folgt einem Link nur, wenn die Direktive Options es erlaubt, mit FollowSymLinks oder SymLinksIfOwnerMatch. Der zweite Wert, auf Shared Hosting häufig, verlangt zusätzlich, dass Link und Ziel demselben Benutzer gehören. Fehlt die Option, endet die Anfrage mit einem 403.

Die von Laravel mitgelieferte Datei public/.htaccess aktiviert FollowSymLinks nicht: Sie beschränkt sich auf Options -MultiViews -Indexes und verlässt sich auf die Serverkonfiguration. mod_rewrite setzt ohnehin voraus, dass der Ordner das Folgen von Links erlaubt: Funktionieren Laravels saubere URLs, ist FollowSymLinks oder SymLinksIfOwnerMatch aktiv, und ein bleibender 403 deutet dann auf die zweite Variante hin, mit einem Link, der nicht dem richtigen Benutzer gehört.

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

    RewriteEngine On
    …
</IfModule>

Folgt der Server keinen Links, oder bist du dir nicht sicher, ergänze am Anfang der Datei ausdrücklich:

public/.htaccess
Options +FollowSymLinks

Zwei Ergebnisse sind möglich: Der 403 verschwindet, oder die ganze Website kippt in einen 500 Internal Server Error. Im zweiten Fall verbietet der Hoster, Options in einer .htaccess zu ändern (AllowOverride ohne Options): Nimm die Zeile wieder heraus und probiere Options +SymLinksIfOwnerMatch, das manchmal geduldet wird. Geht weder das eine noch das andere, bleibt die Fallback-Route weiter unten.

Ursache 3: zu knappe Rechte auf der Ordnerkette

Um storage/app/public/photo.jpg auszuliefern, muss der Apache-Benutzer jeden Ordner des Pfades durchqueren können (Ausführungsrecht) und die Datei lesen dürfen. Ein Ordner storage mit 700, von einem anderen Benutzer oder von einem Deployment als root angelegt, genügt für den 403.

bash
chmod 755 storage storage/app storage/app/public
chmod 644 storage/app/public/*.jpg   # oder find … -type f -exec chmod 644 {} +
chown -R compte:compte storage        # derselbe Benutzer wie der Link, wegen SymLinksIfOwnerMatch

Setze kein 777: Zum Lesen bringt es nichts, und auf einem Shared Hosting ist es gefährlich.

Manche Hostings deaktivieren die PHP-Funktion symlink() (sie steht in disable_functions) und bieten kein SSH: Der Link lässt sich nicht anlegen. Laravel kann die Dateien dann selbst ausliefern, über eine Route, die den Disk public liest.

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() gibt die Datei mit dem richtigen MIME-Typ und den Cache-Headern zurück. Die URL bleibt /storage/photo.jpg, also funktionieren Storage::url() und alle bestehenden Templates weiter. Der Preis: Jedes Bild läuft durch PHP, statt direkt von Apache ausgeliefert zu werden. Auf einer kleinen Website fällt das nicht auf; bei viel Traffic ist der Symlink oder ein externer Speicher (S3 oder gleichwertig) die bessere Wahl.

Prüfen, ob alles sitzt

  1. Der Link existiert und zeigt an die richtige Stelle: ls -l public/ muss storage -> ../storage/app/public anzeigen. Ein roter Link in einem farbigen Terminal ist ein kaputter Link.
  2. Die Datei liegt wirklich auf dem Disk public: php artisan tinker, dann Storage::disk('public')->exists('photo.jpg'). Kommt false zurück, wurde die Datei auf einem anderen Disk gespeichert (local schreibt nach storage/app, nicht nach storage/app/public).
  3. Die erzeugte URL stimmt: Storage::url('photo.jpg') muss /storage/photo.jpg ergeben, und die vollständige URL hängt von APP_URL in .env ab.
  4. Der Server antwortet mit 200: curl -I https://exemple.fr/storage/photo.jpg. Ein 403 nach den drei vorherigen Prüfungen deutet auf Ursache 2.

Warum lokal alles lief

Auf deiner Maschine sehen PHP und der Server dasselbe Dateisystem mit demselben Benutzer, und php artisan serve läuft nicht über Apache: Der absolute Link funktioniert, FollowSymLinks spielt keine Rolle und die Rechte sind die deiner Sitzung. Nichts weist auf das Problem hin, bis zum ersten Deployment. Zwei Gewohnheiten verhindern es: immer --relative, und ein Deploy-Skript, das den Link bei jeder Veröffentlichung neu anlegt.

Für einen Server, den du selbst administrierst, ist die Apache-Konfiguration für Laravel ausführlich beschrieben in Eine Laravel-Anwendung mit Apache auf Debian 13 ausliefern, einem Kapitel der Serie Laravel 13 auf einem Debian-13-Server installieren.

Häufige Fehler

Den Link aus dem falschen Ordner anlegen ln -s ../storage/app/public storage muss aus public/ heraus laufen. Vom Projektstamm aus führt das relative Ziel ins Leere, und Apache antwortet mit 404 oder 403.
Ein alter Link, der noch herumliegt storage:link weigert sich, einen bestehenden Link zu überschreiben („The [public/storage] link already exists“). Lösche ihn zuerst (rm public/storage) oder nimm --force.
APP_URL vergessen Storage::url('photo.jpg') baut die URL aus APP_URL in .env. Ein auf http://localhost stehen gebliebener Wert ergibt in der Produktion kaputte Links, obwohl der Symlink stimmt.
Beim Deployment die Dateien per FTP kopieren Ein FTP-Client überträgt keinen Symlink: Er kopiert einen leeren Ordner oder gar nichts. Der Link muss auf dem Server angelegt werden, per SSH oder durch ein Skript.

Laravel

Damien Flandrin Webentwickler seit 2010, Gründer von Gekkode und Email Impact. Jeder Artikel wird vor der Veröffentlichung an einem echten Projekt getestet. Kontakt
Newsletter

Neue Tests, Tutorials und Projekte, per E-Mail.

Reproduzierbare Tests, versionierter Code, datierte Ergebnisse. Niemals Spam.