Archivos, imágenes y almacenamiento
A dónde van los archivos subidos, por qué S3 es el valor por omisión, y cómo se guarda una subida de Livewire a través de la biblioteca de medios.
Esta guía cubre el disco en el que escribe la aplicación, las variables que necesita, cómo trabajar con el disco local en su lugar, y el único proceso por el que pasa una subida para guardarse: la biblioteca de medios, que es también donde vive la foto de perfil. La necesitas cuando configuras un entorno nuevo y la primera vez que un modelo tuyo tiene foto.
El disco por omisión es S3
config/filesystems.php pone el disco por omisión en env('FILESYSTEM_DISK', 's3'), y .env.example escribe FILESYSTEM_DISK=s3. El kit asume un bucket en todas partes, incluida tu máquina: toda subida y toda llamada a Storage:: sin nombre de disco va a él.
| Variable | En .env.example |
Qué hace |
|---|---|---|
FILESYSTEM_DISK |
s3 |
El disco por omisión: s3, public o local |
AWS_ACCESS_KEY_ID |
relleno | Credenciales, compartidas con el mailer ses y el servicio de SMS |
AWS_SECRET_ACCESS_KEY |
relleno | Igual |
AWS_DEFAULT_REGION |
us-west-1 |
La región del bucket |
AWS_BUCKET |
weblabor-test |
El bucket. Pon el tuyo |
AWS_USE_PATH_STYLE_ENDPOINT |
false |
true para servicios que direccionan los buckets por ruta, como MinIO |
AWS_URL |
no está | URL pública base de los archivos, cuando un CDN o un dominio propio sirve el bucket |
AWS_ENDPOINT |
no está | El endpoint de API de un servicio compatible con S3; déjalo vacío para AWS |
El disco s3 tiene visibility en public, así que todo archivo que se escribe en él se puede leer en su URL directa, y throw en false, así que una escritura fallida regresa false y no registra nada en vez de lanzar un error. Cuando una subida parece no hacer nada, revisa las credenciales y la política del bucket antes que el código.
Usar un disco local en su lugar
Hay dos discos locales definidos. local escribe en storage/app/private, no es público y sirve los archivos con URL temporales firmadas. public escribe en storage/app/public, es público y arma las URL como APP_URL/storage/{ruta}. Para una máquina sin bucket, usa public:
FILESYSTEM_DISK=public
php artisan storage:link
storage:link crea el enlace simbólico public/storage apuntando a storage/app/public, la única entrada de links en config/filesystems.php. Sin él la URL se arma pero el servidor web no encuentra nada ahí, así que cada imagen es un enlace roto. Córrelo una vez por máquina y en cada despliegue que arranque de un clon nuevo.
Las URL de imagen salen de getImageUrl($path, $default = null, $disk = null), el helper detrás de $user->avatar: regresa $default cuando la ruta está vacía o el archivo no está en el disco, la URL directa cuando el visibility del disco es public, y si no, una URL temporal válida por cinco minutos, guardada en caché ese mismo tiempo. El disco local también funciona, entonces, a cambio de una URL firmada por imagen.
Qué produce saveImagesWithThumbs
saveImagesWithThumbs($source, $folder, $name, $width, $height) guarda una imagen redimensionada con miniaturas bajo una ruta simple, y deleteImagesWithThumbs($path) borra un archivo junto con sus miniaturas. El helper está en el paquete weblabormx/laravel-front y redimensiona con intervention/image sobre su driver Imagick, así que PHP necesita la extensión imagick donde corran las subidas. Por cada subida escribe, en la carpeta que pasaste:
| Sufijo | Tamaño | Ajuste |
|---|---|---|
| ninguno | $width×$height como máximo, reducida sólo cuando es mayor |
Conserva proporciones |
s |
90×90 | Cuadrado recortado |
b |
160×160 | Cuadrado recortado |
t |
160×160 | Conserva proporciones |
m |
320×320 | Conserva proporciones |
l |
640×640 | Conserva proporciones |
h |
1024×1024 | Conserva proporciones |
El sufijo va antes de la extensión: billing-addons/addon-1725360000-a1b2c3.jpg tiene al lado billing-addons/addon-1725360000-a1b2c3b.jpg. Los seis tamaños son config('front.thumbnails') del paquete; publica config/front.php para cambiarlos. Una subida heic, heif o avif se convierte a JPEG con calidad 90 y se guarda con extensión .jpg. Todo archivo se escribe con visibilidad public.
Lee una de estas miniaturas con getThumb($rutaOUrl, 'b'), que inserta el sufijo en el último segmento de la cadena que le des, sea ruta o URL completa. Pertenece a los campos de imagen heredados de Laravel Front que guardan archivos de esta forma; una imagen de la biblioteca de medios nunca se lee con ella (mira el párrafo siguiente). Primero llama a validateGetThumb() de app/Helpers/base.php, que regresa la cadena intacta cuando empieza con https://api.dicebear.com o https://www.gravatar.com, avatares generados sin miniaturas a las que apuntar, o con la URL de un disco que usa la biblioteca de medios — S3 incluido — que da Media::diskUrlPrefixes(). Agrega ahí tus propios hosts cuando un modelo pueda guardar una imagen externa.
Una imagen de la biblioteca de medios — la foto de perfil, la imagen de una categoría, cualquier archivo de un campo de imagen — se lee de su registro Media con displayUrlFor($tamañoMostrado), donde $tamañoMostrado es el tamaño en píxeles con el que se muestra. Regresa la copia generada más pequeña que cubra el doble de ese tamaño, y el archivo original cuando ninguna copia generada cubre el tamaño mostrado o todavía no se generó ninguna:
<img src="{{ $user->avatarMedia?->displayUrlFor(112) ?? $user->default_avatar }}" alt="{{ $user->name }}">
Qué hace Livewire antes de que corra tu código
Livewire guarda primero la subida en una ubicación temporal; config/livewire.php la gobierna bajo temporary_file_upload:
| Llave | Valor | Efecto |
|---|---|---|
disk |
LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK, vacío |
El disco de los archivos temporales; vacío significa el disco por omisión, o sea S3 de fábrica |
rules |
required, file, max_file_weight: MULTIMEDIA_MAX_FILESIZE |
El límite de subida del proyecto, 110 MB por omisión, en lugar de los 12 MB de Livewire. Se cambia solo con MULTIMEDIA_MAX_FILESIZE; la regla propia de cada pantalla puede ser más estricta, nunca más laxa. Un archivo que lo pasa se rechaza con la misma redacción de la comprobación del navegador, en MB |
directory |
null |
livewire-tmp en ese disco |
middleware |
null |
throttle:60,1 en el endpoint de subida |
preview_mimes |
lista | Extensiones a las que se les permite una URL temporal de vista previa; incluye heic y heif |
max_upload_time |
60 |
Minutos antes de que una subida inconclusa se invalide; suficiente para un archivo en el límite con una conexión lenta |
cleanup |
true |
Los archivos temporales con más de 24 horas se eliminan |
Dos consecuencias importan. Con el disco temporal en S3, el navegador sube directo al bucket con una URL prefirmada, así que las reglas CORS del bucket deben permitir PUT desde tu dominio; en un disco local el archivo pasa por tu servidor y no hay CORS de por medio. La biblioteca de medios y los campos de archivo e imagen de tus formularios no usan esa URL única en S3: suben por partes de 10 MB, cada una con su propio permiso corto, reintentadas si fallan y retomadas tras un corte de conexión, y revisan el tamaño antes de empezar y otra vez cuando el archivo se une, así que el límite se cumple también en S3. La regla CORS del bucket y sus permisos para esto están en las notas de despliegue de la documentación para desarrolladores. Y un campo que lee su subida a través de temporaryUrl() sólo funciona con las extensiones de preview_mimes: avif no está en esa lista, así que un campo cuyas reglas acepten AVIF y que lea el archivo de esa forma pasa la validación y luego falla con una excepción de "no previsualizable". Agrega 'avif' a preview_mimes si quieres aceptarla. En un disco local esa URL es una ruta firmada sobre APP_URL, que el propio servidor descarga, así que APP_URL debe resolverse desde la máquina que corre PHP. El proceso de la biblioteca de medios que sigue abajo lee el archivo desde su ruta temporal guardada en vez de llamar a temporaryUrl(), así que nunca choca con este límite. Toma de ahí un archivo de cualquier tipo — un PDF, una pista de audio, un video o un SVG además de una imagen — y elimina la copia temporal en cuanto el archivo se guarda o se rechaza. Aun así, una pantalla puede acotarlo. La imagen de una categoría solo acepta imágenes — SVG, JPEG, PNG, GIF, WebP, HEIC o AVIF: su diálogo de archivos ofrece solo imágenes, y cualquier otro archivo se rechaza antes de guardarse, con "La imagen debe ser un archivo de imagen." debajo del campo, así que nunca ocupa espacio.
La foto de perfil pasa por la biblioteca de medios
La foto de perfil es un registro Media en la biblioteca de medios propia de la cuenta, referenciado por una llave foránea avatar_media_id en users, no por una columna de ruta. App\Livewire\Auth\MyProfile muestra el componente de subida de imágenes, el mismo que usa cualquier formulario para guardar archivos en la biblioteca de medios, y App\Livewire\Traits\HasProfilePhoto aplica lo que eligió:
<img src="{{ $user->avatarMedia?->displayUrlFor(112) ?? $user->default_avatar }}" alt="{{ $user->name }}">
<x-image-uploader wire:model.live="photo" :model="$user" relation="avatarMedia" folder="Profile" :max-size="5120" hide-id-field compact />
public function updatedPhoto()
{
$changes = is_array($this->photo) ? $this->photo : [];
if (empty($changes['attach']) && empty($changes['detach']) && empty($changes['delete'])) {
return;
}
$this->user->saveAvatarChanges($changes);
$this->user->refresh();
$this->photo = [];
$this->dialog()->success(__('Success'), __('Avatar updated correctly'));
}
El componente sube la imagen en cuanto se elige, con una barra de progreso, y wire:model.live="photo" pasa su elección a updatedPhoto() de inmediato; no hay botón Guardar. User::saveAvatarChanges() relaciona la imagen con la cuenta y escribe su id en avatar_media_id, y quitar la foto actual en el componente deja la columna vacía. La subida corre a través de App\Jobs\Media\FileUpload, que la guarda en la carpeta Profile de la biblioteca de medios de la cuenta y genera las conversiones que define la biblioteca de medios (thumb-64, thumb-128, thumb, thumb-512), el mismo proceso que usa la pantalla de la Biblioteca de medios (/app/media) para cualquier otra subida. Leer la imagen de vuelta nunca toca getThumb() ni getImageUrl(): $user->avatarMedia?->displayUrlFor($tamañoMostrado) elige la conversión generada más pequeña que aún cubra el doble del tamaño mostrado, o el original cuando ninguna lo cubre, y $user->avatar siempre regresa la conversión thumb. Mientras avatar_media_id esté vacío no hay medio al que preguntarle, así que cae en $user->default_avatar, un avatar generado por Dicebear a partir del correo de la cuenta, que es también lo que regresa $user->avatar en ese caso.
Subir una foto nueva borra la anterior sólo cuando la persona elige Borrarla de la biblioteca en el diálogo de reemplazo, o cuando el uploader corre con :confirm-replacement="false" y la persona puede borrarla. Si no, el registro Media viejo se queda en la biblioteca de la cuenta, desligado de ella, y sólo el puntero avatar_media_id se mueve a la subida nueva. Y como esta subida pertenece a la cuenta igual que cualquier otro archivo de su biblioteca, cuenta contra la cuota de espacio en disco de la cuenta. La imagen de una categoría sigue a quien es dueño de la categoría: la que se sube desde /app para una categoría propia del usuario cuenta contra el espacio de ese usuario, igual que la foto de perfil, mientras que la que se sube desde el catálogo de administración va a General/Categories/{id de la categoría}, en la carpeta General sin dueño de la biblioteca de medios que se abre desde Medios en el panel de administración, y no cuenta contra el espacio de nadie.
A dónde va cada subida
Nada de lo que sube un formulario queda suelto en lo más alto de una biblioteca. Cada archivo va a una carpeta con el nombre, en inglés, de lo que es, y a una subcarpeta con el id del registro cuando el registro guarda sus propios archivos:
| Subida | Carpeta |
|---|---|
| La foto de perfil | Profile |
| La imagen de una categoría | Categories/{id de la categoría} |
| El ícono y las capturas de un complemento | General/Add-ons/{id del complemento} |
| Un adjunto de un ticket | Support tickets/{id del ticket}; los del equipo de soporte, bajo General |
Cualquier otro <x-file-uploader>, <x-image-uploader>, FileUploader o ImageUploader sin folder |
El nombre del modelo en plural, como Products, más /{id} cuando el registro ya existe y el campo admite varios archivos: Products/12 |
Un folder que pases siempre manda. Una categoría, un complemento o un ticket que se está creando todavía no tiene id, así que sus archivos esperan en Categories, Add-ons o Support tickets y pasan a la carpeta del registro al guardarlo. Los archivos guardados antes de esta regla los movió una migración al desplegar; sólo cambió la carpeta en la que aparece cada uno, nunca el archivo.
Al abrir una imagen en la biblioteca de medios, Tamaños lista las copias más pequeñas hechas a partir de ella (cuadrados de 256 px siempre, y de 64, 128 y 512 px cuando la imagen es lo bastante grande), cada una con su medida, su peso y un enlace que la abre. Esas copias cuentan en el espacio que ocupa la imagen.
