
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.
Qué hace php artisan storage:link
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.
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/publicEl 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:
rm public/storage # eliminar el antiguo enlace absoluto
php artisan storage:link --relative
ls -l public/
# storage -> ../storage/app/publicSin acceso a Artisan, lo mismo por SSH, situándote dentro de la carpeta public:
cd public
rm -f storage
ln -s ../storage/app/public storageEl 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:
'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.
<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:
Options +FollowSymLinksDos 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.
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 SymLinksIfOwnerMatchNo 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.
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
- El enlace existe y apunta al lugar correcto:
ls -l public/debe mostrarstorage -> ../storage/app/public. Un enlace en rojo en una terminal con color es un enlace roto. - El archivo está realmente en el disco public:
php artisan tinkery luegoStorage::disk('public')->exists('photo.jpg'). Si devuelvefalse, el archivo se ha guardado en otro disco (localescribe enstorage/app, no enstorage/app/public). - La URL generada es correcta:
Storage::url('photo.jpg')debe dar/storage/photo.jpg, y la URL completa depende deAPP_URLen.env. - 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
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.storage:link se niega a sobrescribir un enlace existente («The [public/storage] link already exists»). Bórralo primero (rm public/storage) o usa --force.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.

