Laravel storage:link: corregir el 403 Forbidden en OVH y Apache

php artisan storage:link crea el enlace y los archivos de storage/app/public responden 403: las tres causas (enlace absoluto que el servidor no sigue, FollowSymLinks desactivado, permisos), las correcciones por orden y la ruta de emergencia cuando los enlaces simbólicos están prohibidos.

Error 403 Forbidden con el comando storage:link en Laravel
Respuesta rápida

En un alojamiento compartido, el enlace que crea php artisan storage:link apunta a una ruta absoluta que el servidor web no sigue, de ahí el 403. Vuelve a crearlo en relativo: php artisan storage:link --relative, o por SSH desde la carpeta public: ln -s ../storage/app/public storage. Si los enlaces simbólicos están prohibidos, sirve los archivos con una ruta de Laravel.

Has lanzado php artisan storage:link, el comando ha respondido que el enlace estaba creado y, aun así, cada imagen de storage/app/public devuelve 403 Forbidden. En local todo funciona; el problema aparece en un alojamiento compartido, con OVH a la cabeza. Esto es lo que hace el comando, por qué el servidor se niega a servir los archivos y las correcciones en el orden en el que hay que probarlas.

Laravel guarda los archivos que suben los usuarios en storage/app/public, fuera de la raíz web, por seguridad. Para hacerlos accesibles no los copia: crea un enlace simbólico public/storage que apunta a storage/app/public. Cuando el navegador pide https://exemple.fr/storage/photo.jpg, Apache sigue el enlace y sirve 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

El detalle que cuenta está en la última línea: por defecto, el enlace es absoluto. Contiene la ruta completa de la carpeta storage tal y como PHP la ve en el momento del comando.

Por qué el servidor responde 403

Tres causas, de la más frecuente a la más rara. El mensaje es el mismo en los tres casos; lo que las distingue es la configuración del alojamiento.

Causa 1: una ruta absoluta que el servidor web no ve

En un alojamiento compartido, PHP y Apache no siempre trabajan con la misma ruta raíz. La cuenta está montada bajo una ruta (por ejemplo /homez.123/compte/) que PHP ha escrito en el enlace, mientras que Apache sirve el sitio desde un alias (/home/compte/) o desde un entorno aislado. El enlace apunta a una carpeta que Apache no puede alcanzar: la rechaza, 403.

La corrección consiste en crear un enlace relativo, que no depende de ninguna ruta raíz. Desde Laravel 8, el comando lo hace directamente:

bash
rm public/storage                      # eliminar el antiguo enlace absoluto
php artisan storage:link --relative
ls -l public/
# storage -> ../storage/app/public

Sin acceso a Artisan, lo mismo por SSH, situándote dentro de la carpeta public:

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

El destino ../storage/app/public se resuelve respecto a la ubicación del enlace, es decir public/: sube un nivel y luego baja a storage/app/public. Sigue siendo válido sea cual sea la ruta de montaje.

Para que los despliegues futuros conserven esta elección, declara el enlace en la configuración en lugar de confiar en el valor por defecto:

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

Y llama siempre a php artisan storage:link --relative en tu script de despliegue, después del composer install.

Causa 2: Apache no tiene permiso para seguir los enlaces simbólicos

Apache solo sigue un enlace si la directiva Options lo autoriza, con FollowSymLinks o SymLinksIfOwnerMatch. Este segundo valor, frecuente en los alojamientos compartidos, exige además que el enlace y su destino pertenezcan al mismo usuario. Si la opción falta, la petición acaba en un 403.

El archivo public/.htaccess que trae Laravel no activa FollowSymLinks: se limita a Options -MultiViews -Indexes y se remite a la configuración del servidor. Ahora bien, mod_rewrite exige de todas formas que la carpeta permita seguir los enlaces: si las URL limpias de Laravel funcionan, FollowSymLinks o SymLinksIfOwnerMatch está activo, y un 403 que persiste señala entonces al segundo, con un enlace que no pertenece al usuario correcto.

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

    RewriteEngine On
    …
</IfModule>

Si el servidor no sigue los enlaces, o si no lo tienes claro, añade explícitamente, al principio del archivo:

public/.htaccess
Options +FollowSymLinks

Dos resultados posibles: el 403 desaparece, o el sitio entero pasa a 500 Internal Server Error. En este segundo caso, el alojamiento prohíbe cambiar Options en un .htaccess (AllowOverride sin Options): quita la línea y prueba Options +SymLinksIfOwnerMatch, que a veces se tolera. Si no pasa ni uno ni otro, la solución es la ruta de emergencia de más abajo.

Causa 3: permisos insuficientes en la cadena de carpetas

Para servir storage/app/public/photo.jpg, el usuario de Apache debe poder atravesar cada carpeta de la ruta (permiso de ejecución) y leer el archivo. Una carpeta storage en 700, creada por otro usuario o por un despliegue como root, basta para provocar el 403.

bash
chmod 755 storage storage/app storage/app/public
chmod 644 storage/app/public/*.jpg   # o find … -type f -exec chmod 644 {} +
chown -R compte:compte storage        # el mismo usuario que el enlace, para SymLinksIfOwnerMatch

No apliques 777: es inútil para la lectura y peligroso en un alojamiento compartido.

La ruta de emergencia: servir los archivos sin enlace simbólico

Algunos alojamientos desactivan la función PHP symlink() (aparece en disable_functions) y no ofrecen SSH: es imposible crear el enlace. Laravel puede entonces servir los archivos él mismo, mediante una ruta que lee el 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() devuelve el archivo con el tipo MIME correcto y las cabeceras de caché. La URL sigue siendo /storage/photo.jpg, así que Storage::url() y todas las plantillas existentes siguen funcionando. El coste: cada imagen pasa por PHP en lugar de servirla Apache directamente. En un sitio pequeño es invisible; en un sitio con mucho tráfico, es preferible el enlace simbólico o un almacenamiento externo (S3 o equivalente).

Comprobar que todo está en su sitio

  1. El enlace existe y apunta al lugar correcto: ls -l public/ debe mostrar storage -> ../storage/app/public. Un enlace en rojo en una terminal con color es un enlace roto.
  2. El archivo está realmente en el disco public: php artisan tinker y luego Storage::disk('public')->exists('photo.jpg'). Si devuelve false, el archivo se ha guardado en otro disco (local escribe en storage/app, no en storage/app/public).
  3. La URL generada es correcta: Storage::url('photo.jpg') debe dar /storage/photo.jpg, y la URL completa depende de APP_URL en .env.
  4. El servidor responde 200: curl -I https://exemple.fr/storage/photo.jpg. Un 403 después de las tres comprobaciones anteriores señala la causa 2.

Por qué en local funcionaba todo

En tu máquina, PHP y el servidor ven el mismo sistema de archivos con el mismo usuario, y php artisan serve no pasa por Apache: el enlace absoluto funciona, FollowSymLinks no entra en juego y los permisos son los de tu sesión. Nada avisa del problema antes del primer despliegue. Dos costumbres lo evitan: siempre --relative, y un script de despliegue que vuelva a crear el enlace en cada puesta en producción.

Para un servidor que administras tú mismo, la configuración de Apache para Laravel está detallada en Servir una aplicación Laravel con Apache en Debian 13, capítulo de la serie Instalar Laravel 13 en un servidor Debian 13.

Errores frecuentes

Crear el enlace desde la carpeta equivocada ln -s ../storage/app/public storage debe lanzarse desde public/. Desde la raíz del proyecto, el destino relativo no lleva a ninguna parte y Apache devuelve 404 o 403.
Un enlace antiguo que sigue ahí storage:link se niega a sobrescribir un enlace existente («The [public/storage] link already exists»). Bórralo primero (rm public/storage) o usa --force.
Olvidar APP_URL Storage::url('photo.jpg') construye la URL a partir de APP_URL en .env. Un valor que se ha quedado en http://localhost da enlaces rotos en producción aunque el enlace simbólico sea correcto.
Desplegar copiando los archivos por FTP Un cliente FTP no transfiere un enlace simbólico: copia una carpeta vacía o nada. El enlace tiene que crearse en el servidor, por SSH o con un script.

Laravel

Damien Flandrin Desarrollador web desde 2010, creador de Gekkode y de Email Impact. Cada artículo se prueba en un proyecto real antes de publicarse. Contacto
Newsletter

Las nuevas pruebas, tutoriales y proyectos, por correo.

Pruebas reproducibles, código versionado, resultados fechados. Nunca spam.