Desarrollo Web 5-8 minutos

Subida de archivos en Laravel 13: almacenamiento, validación e imágenes

Diego Cortés
Diego Cortés
Full Stack Developer & SEO Specialist
Compartir:
Subida de archivos en Laravel 13: almacenamiento, validación e imágenes

Subir archivos en Laravel 13 parece trivial hasta que el formulario crece: nombres duplicados, imágenes con el tamaño equivocado y rutas que nadie encuentra. La fachada Storage, basada en Flysystem, unifica disco local, public y S3 con validación en el servidor y URLs temporales.

Cómo funciona el almacenamiento en Laravel 13

La abstracción Flysystem y la fachada Storage

Laravel 13 mantiene el sistema de archivos que la serie 12 consolidó: una capa construida sobre el paquete PHP Flysystem en la que cada disco representa una ubicación de almacenamiento distinta. La fachada Storage expone una única API para leer, escribir, mover y borrar archivos, de modo que tu código no sabe, ni necesita saber, si el archivo vive en el disco duro del servidor o en un bucket de S3.

Storage::disk('local')->put('informes/enero.txt', $contenido);
$texto = Storage::disk('s3')->get('informes/enero.txt');
Storage::disk('public')->delete('avatares/viejo.jpg');

Los discos local, public y s3 en config/filesystems.php

La configuración vive en config/filesystems.php. De serie encuentras tres discos: local, que guarda en storage/app/private y es invisible para la web; public, que guarda en storage/app/public y se sirve por URL; y s3, que apunta a Amazon S3 o servicios compatibles. El disco por defecto se elige con la variable FILESYSTEM_DISK del entorno, así que el mismo código funciona en tu máquina y en producción.

local frente a public: el enlace simbólico storage:link

El disco public existe para que el navegador pueda cargar el archivo. Para conseguirlo hay que crear el enlace simbólico que expone la carpeta: php artisan storage:link genera public/storage apuntando a storage/app/public. Sin ese paso, las rutas /storage/... devuelven 404 aunque el archivo exista en disco.

php artisan storage:link

Subir archivos desde un formulario

El formulario con enctype multipart/form-data y el objeto UploadedFile

Todo empieza en el formulario: necesita el atributo enctype="multipart/form-data" para enviar binario y un campo de tipo file. En el controlador, $request->file('avatar') devuelve una instancia de UploadedFile con métodos para guardar, validar e inspeccionar el archivo original.

<form method="POST" action="/perfil/avatar" enctype="multipart/form-data">
    @csrf
    <input type="file" name="avatar" accept="image/*">
    <button type="submit">Subir avatar</button>
</form>

store() y storeAs(): guardar con nombres controlados

La forma más corta de guardar es store(): genera un nombre aleatorio, lo coloca en la carpeta indicada y devuelve la ruta resultante. Si necesitas controlar el nombre —por ejemplo, el slug del usuario— usa storeAs(). Guardar en el disco public deja el archivo listo para servirse por URL.

$ruta = $request->file('avatar')->store('avatares', 'public');
// 'avatares/Ab3xY9kQ2m.png' (nombre aleatorio)

$ruta = $request->file('avatar')->storeAs('avatares', $user->slug . '.png', 'public');

putFile() y el streaming de archivos grandes

La fachada Storage también guarda archivos subidos con Storage::putFile(), y para ficheros pesados conviene evitar cargar todo en memoria: escribir desde un stream hace que Laravel lea y escriba por fragmentos, algo imprescindible con vídeos o ZIP de varios cientos de MB.

Storage::disk('s3')->put('videos/' . $nombre, fopen($request->file('clip')->getRealPath(), 'r'));

Validación de archivos en el servidor

Reglas file, image, mimes y max

La validación en Laravel se declara con reglas sobre el campo. Para un avatar típico: image exige que sea una imagen, mimes limita las extensiones, max limita el peso en kilobytes y required evita campos vacíos. El validador gestiona los mensajes de error y el redireccionamiento con los errores por ti.

$datos = $request->validate([
    'avatar' => ['required', 'image', 'mimes:jpeg,png,webp', 'max:2048'],
]);

Validar dimensiones de imagen con dimensions

Si la imagen debe cumplir un tamaño mínimo o máximo, la regla dimensions comprueba los píxeles antes de tocar el disco: dimensions:min_width=200,min_height=200,max_width=4000. Así descartas fotos diminutas o panorámicas absurdas sin procesarlas.

Por qué nunca confiar en la validación del cliente

El navegador puede manipular cualquier formulario, así que la validación real siempre vive en el servidor. No es teoría: el CVE-2026-33687, publicado en junio de 2026, afectaba a Sharp, un CMS construido sobre Laravel con versiones anteriores a la 9.20.0. Su endpoint de subida recibía el parámetro validation_rule directamente del cliente y lo pasaba al validador, de modo que un usuario autenticado podía saltarse las restricciones de tipo de archivo. La lección: las reglas se fijan en el código del servidor, nunca se aceptan de la petición. Para validaciones complejas, un Form Request mantiene reglas y authorize() en un solo sitio.

Visibilidad, URLs y descargas

Archivos públicos con url() y el disco public

Con el enlace simbólico creado, Storage::url() devuelve la ruta pública del archivo: /storage/avatares/foo.png. Para una URL absoluta, combínala con el helper asset(). Es la forma estándar de servir avatares, portadas o documentos descargables sin pasar por un controlador.

$url = Storage::url($user->avatar); // /storage/avatares/foo.png
<img src="{{ asset(Storage::url($user->avatar)) }}" alt="Avatar">

Archivos privados: descargas controladas y URLs temporales en local

Los archivos que no deben ser públicos se guardan en el disco local (storage/app/private) y se sirven a través de un controlador que comprueba permisos antes de devolver la descarga. Y desde Laravel 12, las URLs temporales firmadas —antes exclusivas de S3— también funcionan en el disco local: temporaryUrl() genera un enlace que caduca solo, ideal para adjuntos de facturas o PDFs de un área privada.

return Storage::disk('local')->download($factura->pdf);

$url = Storage::disk('local')->temporaryUrl($factura->pdf, now()->addMinutes(5));

Procesar imágenes con Intervention Image

Redimensionar y recortar la imagen al subir

Intervention Image es el estándar de facto para manipular imágenes en Laravel. Su versión 3 permite abrir el archivo subido, redimensionarlo o recortarlo con cover() y guardar el resultado en el disco que quieras, todo con un par de líneas. Así controlas el peso final y evitas que el usuario suba un PNG de 12 MB.

use Intervention\Image\ImageManager;

$manager = new ImageManager(['driver' => 'gd']);
$manager->read($request->file('avatar')->getRealPath())
    ->cover(400, 400)
    ->save(storage_path('app/public/avatares/' . $nombre));

Generar thumbnails en una cola para no bloquear la petición

Redimensionar dentro de la petición HTTP añade latencia; para thumbnails o versiones múltiples (mini, media, grande) lo correcto es encolar el trabajo. Un job recibe la ruta guardada, genera las variantes y las escribe en el disco public; la respuesta vuelve al usuario sin esperar el procesado. Es el mismo patrón de colas y jobs en Laravel 13 aplicado al procesado de imágenes.

class GenerarThumbnail implements ShouldQueue
{
    public function handle(): void
    {
        $manager = new ImageManager(['driver' => 'gd']);
        $manager->read(storage_path('app/public/' . $this->ruta))
            ->cover(150, 150)
            ->save(storage_path('app/public/thumbs/' . $this->ruta));
    }
}

GenerarThumbnail::dispatch($ruta);

Producción: de local a S3 y compatibles

Configurar el disco S3 con variables de entorno

Cuando la app crece —varios servidores, CDN, backups—, los archivos pasan a almacenamiento objeto. Laravel trae el disco s3 configurado: solo hay que instalar el paquete league/flysystem-aws-s3-v3 y rellenar las variables AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, AWS_BUCKET y AWS_URL en el entorno. El bucket queda disponible como un disco más.

AWS_ACCESS_KEY_ID=xxx
AWS_SECRET_ACCESS_KEY=xxx
AWS_DEFAULT_REGION=eu-west-1
AWS_BUCKET=blenderdeluxe-media
FILESYSTEM_DISK=s3

Migrar archivos entre discos sin reescribir el código

La magia de la abstracción: tu código usa Storage::disk('public') o simplemente la fachada sin disco, y el disco por defecto lo decide FILESYSTEM_DISK. Migrar de local a S3 es cambiar variables de entorno, no reescribir la aplicación. Para mover datos ya existentes, copia entre discos con readStream y writeStream o con un job de migración.

Almacenamiento objeto gestionado: Forge y alternativas

Además de S3, cualquier servicio compatible funciona: DigitalOcean Spaces, MinIO o el almacenamiento objeto gestionado que Laravel Forge añadió en julio de 2026 junto a sus cachés Valkey gestionadas. Todos hablan el mismo protocolo S3, así que el disco s3 de Laravel los consume sin cambios.

Buenas prácticas y errores comunes

Sanear nombres de archivo y limitar extensiones

Nunca uses el nombre original del usuario para guardar: puede contener rutas, caracteres raros o extensiones dobles. Genera siempre el nombre (Str::random, un UUID o el slug del modelo) y deja que storeAs() lo coloque. Además, recuerda que mimes valida el contenido real del archivo, no solo la extensión, y combínala siempre con max para el peso.

Límites de tamaño, tiempo de subida y permisos de carpeta

Tres clásicos que rompen subidas en producción: upload_max_filesize y post_max_size en php.ini, client_max_body_size en Nginx y los permisos de escritura en storage/. Un error típico es un 413 o un 500 silencioso al subir un archivo grande cuando todo funcionaba en local: revisa esos tres puntos antes de tocar el código.

Conclusión

Subir archivos en Laravel 13 deja de ser un dolor cuando entiendes el modelo de discos: un formulario con multipart/form-data, validación real en el servidor, guardado con store() o storeAs(), el enlace storage:link para lo público y URLs temporales para lo privado. El ejemplo de este post —el módulo de avatares de un blog como blenderdeluxe— cubre ese flujo completo: validación, thumbnail en cola y archivos servidos por URL pública. Si quieres seguir blindando formularios, echa un vistazo a la validación con Form Requests y a las colas y jobs para el procesado pesado.

Categorías