Recibir un fichero en el servidor y copiarlo al bucket es la forma por defecto de hacerlo, y para un avatar está perfectamente bien. Para un vídeo de dos gigas, ese mismo camino te obliga a pelearte con la memoria, los tiempos de espera y media configuración de PHP. Aquí verás cómo firmar la subida para que el byte vaya del navegador al bucket y los tres detalles que rompen la implementación cuando la haces por primera vez. Los ejemplos van en Laravel, una de nuestras especialidades, pero la firma es un mecanismo del propio S3 y se aplica igual desde cualquier lenguaje.
El fichero que pasa por PHP te cuesta memoria, tiempo y límites
La forma por defecto de recibir un fichero es que el navegador lo mande a tu servidor, tu servidor lo guarde en disco temporal y tu código lo copie al bucket.
Para un avatar de 200 kilobytes es perfecto y no hay motivo para complicarlo. Para un vídeo de dos gigas el mismo camino te obliga a pelearte con cuatro cosas a la vez:
upload_max_filesize y post_max_size en PHP, que hay que subir en el contenedor y en el servidor web. El tiempo máximo de petición, porque una subida lenta desde una conexión mala mantiene un proceso PHP ocupado durante minutos. La memoria, según cómo se maneje el flujo. Y el ancho de banda, porque el byte viaja dos veces: del navegador a tu máquina y de tu máquina al bucket.
Y hay un coste que no se ve en ninguna métrica: cada subida grande te ocupa un trabajador PHP que no está sirviendo páginas.
La firma la da tu servidor, los bytes van directos
La alternativa lleva años en el SDK de S3 y sigue infrautilizada. El servidor no recibe el fichero, recibe la petición de permiso.
El flujo tiene tres pasos y solo el primero y el tercero tocan tu aplicación:
Uno. El navegador pide una autorización de subida indicando el tipo de fichero y el tamaño. Tu servidor firma una URL temporal contra el bucket y la devuelve.
Dos. El navegador hace un PUT con el binario directamente contra esa URL. Tu aplicación no se entera y no consume nada.
Tres. El navegador avisa a tu aplicación de que ha terminado, con la clave del objeto. Tu aplicación registra el fichero en la base de datos y lanza lo que tenga que hacer después.
El resultado es que el fichero nunca pasa por PHP y los límites de configuración dejan de importar. Y la firma caduca, así que la autorización no es reutilizable.
El fichero llega antes que el modelo, y ahí está el problema de verdad
Aquí es donde casi todas las implementaciones caseras se atascan, y es un problema de orden.
Imagina un formulario de creación. El usuario adjunta el documento antes de haber guardado el registro, así que en el momento de firmar la subida todavía no existe el identificador al que ese fichero pertenecerá. No puedes construir la ruta definitiva porque no la conoces.
La solución que funciona es de dos fases. Si al firmar ya se conocen el modelo y la colección, la clave se construye directamente en su sitio. Si no, el fichero aterriza en una carpeta temporal, y al confirmar se mueve a su ruta canónica.
Y ese movimiento es la parte que hay que hacer bien: no se descarga y se vuelve a subir, se copia dentro del bucket y se borra el origen.
$client->copyObject([
'Bucket' => $bucket,
'CopySource' => "{$bucket}/" . rawurlencode($fromKey),
'Key' => $toKey,
]);
$client->deleteObject(['Bucket' => $bucket, 'Key' => $fromKey]);
Dos llamadas a la API y cero bytes atravesando tu servidor. Si en lugar de eso descargas y subes, has vuelto exactamente al problema que querías evitar, solo que ahora ocurre después.
El detalle del prefijo que rompe las subidas firmadas
Hay una clase de error que solo aparece cuando dos capas hacen lo mismo sin saber la una de la otra, y las rutas de S3 son terreno abonado. El síntoma es un fichero que se sube sin errores y luego no está donde debería.
Si tu disco tiene configurada una raíz, del tipo dev/ o produccion/, Laravel la aplica sola en todas las lecturas y escrituras. Pero cuando firmas un PUT que va a ejecutar el navegador, esa capa no interviene: el navegador habla directamente con el bucket.
Si firmas con la clave relativa, el objeto se sube sin el prefijo, y a partir de ahí ninguna lectura desde tu aplicación lo encuentra. El fichero está, ocupa espacio y factura, pero para tu código no existe.
La solución es aplicar el prefijo a mano al firmar y guardar en base de datos la clave relativa, para que el resto de la aplicación siga funcionando como siempre:
$root = trim((string) ($cfg['root'] ?? ''), '/');
$fullKey = $root !== '' ? "{$root}/{$key}" : $key;
Firma siempre con ContentType y ContentLength incluidos. No es cosmético: son parte de lo que se firma, así que el navegador no puede subir un tipo distinto ni un fichero más grande del autorizado. Si firmas sin ellos, has entregado una autorización de subida sin límite de tamaño, y esa es la clase de detalle que aparece en la factura antes que en los registros.
El endpoint que firma es el que decide quién sube dónde
Una firma es un permiso de escritura con fecha de caducidad. El endpoint que la emite no está devolviendo una URL: está decidiendo, en cada llamada, qué puede escribir quién y en qué sitio de tu bucket.
Cuando expones un endpoint que firma subidas, estás delegando escritura en tu bucket. Cualquiera que pueda llamarlo obtiene permiso de escritura sobre la clave que pida. Así que ese endpoint tiene que comprobar tres cosas y ninguna viene de serie:
Que el disco esté en una lista blanca. Si tienes cinco buckets separados por tipo de contenido, un usuario no debería poder pedir firma contra cualquiera de ellos solo porque está autenticado.
Que el usuario sea dueño del modelo al que dice adjuntar. Si la ruta canónica se construye a partir del tipo y el identificador que manda el cliente, sin comprobar propiedad, un usuario puede escribir dentro de la carpeta de otro.
Y un límite de peticiones. Firmar es barato, pero permite iniciar subidas, y las subidas iniciadas y abandonadas siguen ocupando espacio.
Ninguna librería puede resolver esto por ti, porque la propiedad de un modelo es lógica de tu dominio. Lo que sí debe darte es el punto donde engancharlo.
Ficheros grandes: multipart, y el umbral es solo una pista
Por encima de cierto tamaño una sola petición deja de ser buena idea: si falla al noventa por ciento, se pierde todo.
Ahí entra la subida por partes, que son cuatro operaciones: iniciar la sesión, pedir URLs firmadas para cada trozo, completar, y abortar si el usuario se arrepiente. El navegador sube los trozos en paralelo y puede reintentar solo el que falle.
Un detalle de arquitectura que conviene entender: el umbral a partir del cual se usa multipart lo decide el navegador, porque es quien conoce el tamaño real del fichero antes de subirlo. El servidor no lo impone. Es lógico, y a la vez significa que un cliente puede iniciar multipart para un fichero pequeño, así que el límite de peticiones también tiene que cubrir esos endpoints.
Y hay una consecuencia operativa que no se ve hasta que llega la factura: una sesión multipart abandonada deja sus trozos vivos en el bucket. No son un objeto visible, pero se cobran. Si activas multipart, necesitas una tarea programada que aborte las sesiones caducadas.
Las variantes son ficheros, no campos
Una miniatura y una versión mediana son el mismo tipo de cosa que el original: un fichero en un bucket con su tamaño, su mime y su ruta. Guardarlas como columnas del registro las convierte en otra cosa, y esa decisión se paga cuando aparece la cuarta variante.
Cuando subes una imagen y generas una miniatura y una versión mediana, hay dos formas de guardarlas. Como columnas del mismo registro, del tipo thumb_path y medium_path, o como registros propios que apuntan al original.
La segunda es más trabajo al principio y resuelve sola todo lo que viene después: cada variante tiene su propio tamaño, su propio tipo, su propia URL firmada y su propio control de acceso, y añadir una variante nueva no es una migración. Borrar el original borra sus variantes en cascada porque la relación es real.
Con columnas, cada formato nuevo es una columna nueva, y el día que quieres una marca de agua distinta por variante no tienes dónde ponerla.
Qué hay en el mercado y en qué se diferencia
Las tres opciones razonables, con su compromiso real.
Las subidas de Livewire son la vía más corta y para la mayoría de casos son la respuesta correcta. Un wire:model sobre un campo de fichero y ya está. El compromiso es exactamente el de este artículo: el binario atraviesa PHP y aterriza en un disco temporal antes de llegar a su destino. Para documentos y fotos va perfecto; para vídeo, no.
spatie/laravel-medialibrary es la referencia del ecosistema para asociar ficheros a modelos, con colecciones, conversiones de imagen y una comunidad enorme detrás. Si tu necesidad es "adjuntar imágenes a un modelo y generar miniaturas", es una elección sólida y muy probada. La diferencia de enfoque que conviene valorar es si la subida directa al bucket es un camino de primera clase o un añadido, porque eso condiciona el tamaño de fichero que puedes aceptar sin tocar la configuración del servidor.
Y Storage::putFile() a pelo no tiene nada de malo. Si subes cuatro ficheros pequeños al mes, cualquier abstracción encima es peso muerto.
El principio que a nosotros nos guió es que las credenciales de los discos siguen viviendo en config/filesystems.php. Una capa de gestión de ficheros que te pide configurar otra vez las claves de tu bucket ha duplicado configuración, y las configuraciones duplicadas se desincronizan.
Y si no trabajas con Laravel
Los criterios son del diseño y valen en cualquier lenguaje:
- Firma en el servidor, sube desde el cliente. Es la única forma de que el tamaño del fichero deje de ser un problema de infraestructura.
- Incluye tipo y tamaño en la firma. Una autorización sin límites es una autorización peligrosa.
- Resuelve el orden con una carpeta temporal y un movimiento dentro del bucket. Nunca descargando y volviendo a subir.
- Trata el endpoint de firma como lo que es, un punto de escritura delegada, con lista blanca de destinos y comprobación de propiedad.
- Y limpia lo abandonado. Los ficheros temporales y las sesiones multipart caducadas facturan igual que los buenos.
Dónde está el código
El paquete está en Packagist, el código en GitHub y la documentación del paquete recoge la configuración completa, que es extensa: colecciones, políticas de acceso, variantes, previsualizaciones de PDF y vídeo, marcas de agua y extracción de texto.
Está en producción en Crowd Legal, con cinco buckets separados por tipo de contenido, y en Abodara.
Si tu producto acepta ficheros de usuarios y ya has tenido que tocar los límites de PHP para que quepan, esto es lo que hay que cambiar. Lo trabajamos en aplicaciones web y SaaS a medida, y si nos cuentas qué suben tus usuarios te decimos qué montaje te vale.