[Guía probada en el campo] Gestión de medios en Laravel: Storage/Optimización/Entrega por CDN para imágenes, videos y documentos, con texto alternativo accesible, subtítulos y captions
Qué aprenderás (puntos destacados)
- Diseñar el Filesystem (Storage) y guardar de forma segura en almacenamiento externo como S3
- Optimización de imágenes (resize, WebP/AVIF, lazy loading,
srcset
/sizes
) y control de caché - Seguridad como URLs firmadas,
Content-Disposition
, validación de MIME y escaneo de virus - Entrega de video en streaming, subtítulos (WebVTT), transcripciones y respeto de
prefers-reduced-motion
- Políticas de distribución para documentos PDF/Office y provisión de resúmenes/contenido alternativo
- Diseñar texto alternativo y captions accesibles, y crear un flujo editorial
- Operaciones, monitorización y tests (Dusk/Feature) más una checklist
Lectores previstos (quién se beneficia)
- Ingenieros Laravel de nivel principiante a intermedio: quienes quieren manejar imágenes/archivos con seguridad mientras mejoran rendimiento y accesibilidad a la vez
- Tech leads en proyectos para clientes/SaaS interno: quienes quieren estandarizar una arquitectura de medios extensible basada en S3/CDN
- Diseñadores/editores de contenido: quienes quieren un flujo operativo que les permita escribir alt text, captions y subtítulos sin dudas
- QA/especialistas de accesibilidad: quienes quieren aplicar amigabilidad con lectores de pantalla, operabilidad por teclado y UI independiente del color también a los medios
1. Introducción: Las funciones de medios se apoyan en tres pilares—“Rendimiento × Seguridad × Comprensibilidad”
Los medios (imágenes, video, PDFs, etc.) son centrales en la experiencia de usuario, pero “se puede mostrar” no basta.
- Rendimiento: archivos ligeros, dimensionado correcto, caché, CDN
- Seguridad: validación de MIME/extensión, escaneo de virus, URLs firmadas, autorización
- Comprensibilidad: texto alternativo, subtítulos, captions, transcripciones, controles operables
Laravel puede implementar de extremo a extremo con Storage/Flysystem como núcleo, más autorización, URLs firmadas y control de respuesta. Este artículo ofrece código “copiar/pegar” y patrones operativos para uso real.
2. Diseño de destinos de almacenamiento: Filesystem (Storage) y elección entre S3/local
2.1 Configuración básica
Define discos (destinos de almacenamiento) en config/filesystems.php
. Usa local y S3 y elige según el caso de uso.
// .env
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_DEFAULT_REGION=ap-northeast-1
AWS_BUCKET=example-bucket
AWS_URL=https://cdn.example.com // URL pública vía CDN (opcional)
// Guardar
$path = Storage::disk('s3')->putFile('uploads/images', $request->file('image'), 'private');
// URL pública (vía CDN si está configurado)
$url = Storage::disk('s3')->url($path);
// Exponer temporalmente (10 minutos)
$tmpUrl = Storage::disk('s3')->temporaryUrl($path, now()->addMinutes(10), [
'ResponseContentDisposition' => 'inline',
]);
Puntos de diseño:
- Los elementos seguros de exponer deben servirse mediante bucket público o a través de un CDN. Para solo descarga, configura
Content-Disposition: attachment
. - Como regla, guarda los archivos privados y emite URLs firmadas solo al descargar.
- Si usas multi-región/CNAME (CDN), usa
AWS_URL
.
2.2 Persistencia de metadatos
Gestiona metadatos de medios en la base de datos para soportar regeneración y captions por idioma.
Schema::create('media', function (Blueprint $t) {
$t->id();
$t->string('disk')->default('s3');
$t->string('path');
$t->string('mime');
$t->unsignedInteger('size');
$t->json('variants')->nullable(); // p. ej., 'thumb','webp','avif'
$t->json('meta')->nullable(); // p. ej., 'alt','caption','lang','transcript'
$t->foreignId('user_id')->nullable();
$t->timestamps();
});
3. Seguridad de subida: validación, sanitización y escaneo
3.1 Validación
$request->validate([
'image' => ['required','file','mimetypes:image/jpeg,image/png,image/webp','max:5120'], // 5MB
]);
- El atributo
accept
del lado cliente solo ayuda; siempre valida el MIME en el servidor. max
está en KB. Especifica límites de tamaño y comunícalos en la UI.
3.2 Manejo de EXIF/geotags
Elimina EXIF al subir para evitar fuga de ubicación. La mayoría de librerías (p. ej., Intervention Image) pueden eliminar metadatos.
3.3 Escaneo de virus
Usa un servicio externo o ClamAV para escanear asíncronamente justo después de subir (job en cola). Si es positivo, pon en cuarentena e informa al usuario con un mensaje seguro.
4. Optimización de imágenes: variantes derivadas (resize/convert) y entrega responsive
4.1 Generación de variantes (job)
Guarda el original en privado y genera derivados (miniaturas, S/M/L, WebP/AVIF) en una cola.
class GenerateImageVariants implements ShouldQueue {
public function __construct(public int $mediaId) {}
public function handle(ImageService $svc): void {
$media = Media::findOrFail($this->mediaId);
$variants = $svc->makeVariants($media); // p. ej., ['w320','w640','w1280','webp','avif']
$media->update(['variants' => $variants]);
}
}
Ejemplo de servicio (concepto):
class ImageService {
public function makeVariants(Media $m): array {
$src = Storage::disk($m->disk)->get($m->path);
// Redimensionar a varios tamaños, ajustar calidad JPEG, convertir a WebP/AVIF, etc.
// Tras guardar, devolver clave -> URL
return [
'w320' => Storage::url("variants/{$m->id}/img@320.jpg"),
'w640' => Storage::url("variants/{$m->id}/img@640.jpg"),
'w1280'=> Storage::url("variants/{$m->id}/img@1280.jpg"),
'webp' => Storage::url("variants/{$m->id}/img.webp"),
'avif' => Storage::url("variants/{$m->id}/img.avif"),
];
}
}
4.2 srcset
/sizes
y <picture>
@php $v = $media->variants; @endphp
<picture>
@if(isset($v['avif']))
<source type="image/avif" srcset="{{ $v['avif'] }}">
@endif
@if(isset($v['webp']))
<source type="image/webp" srcset="{{ $v['webp'] }}">
@endif
<img
src="{{ $v['w640'] ?? $media->url }}"
srcset="{{ $v['w320'] ?? '' }} 320w, {{ $v['w640'] ?? '' }} 640w, {{ $v['w1280'] ?? '' }} 1280w"
sizes="(max-width: 640px) 90vw, 640px"
alt="{{ $media->meta['alt'] ?? '' }}"
width="640" height="400"
loading="lazy" decoding="async"
>
</picture>
Puntos de implementación:
- Especifica width y height para reducir CLS (cambios de diseño).
- Usa
loading="lazy"
ydecoding="async"
para mejorar la velocidad percibida. - Usa
<picture>
para priorizar AVIF/WebP y caer a JPEG/PNG donde no se soporten.
4.3 Control de caché y ETag
Al entregar vía CDN, incluye hash de contenido en el nombre del archivo en build y añade Cache-Control: public, max-age=31536000, immutable
. Para respuestas dinámicas, usa ETag/Last-Modified.
5. Alt text y captions: haz que el proceso editorial sea a prueba de fallos
5.1 UI de entrada y almacenamiento
<x-form.input id="alt" name="meta[alt]" label="Texto alternativo" help="Se usa cuando la imagen no puede mostrarse o por lectores de pantalla." />
<x-form.input id="caption" name="meta" label="Caption (opcional)" />
<x-form.input id="lang" name="meta[lang]" label="Código de idioma" help="p. ej., ja, en" />
Guardar como meta
unificado:
$media->update([
'meta' => [
'alt' => $request->input('meta.alt'),
'caption' => $request->input('meta.caption'),
'lang' => $request->input('meta.lang','ja'),
],
]);
5.2 Guías para buen alt text
- Explica el propósito de la imagen (p. ej., “Fachada de la tienda bajo cielo azul”).
- Palabras como “imagen” o “foto” son innecesarias (redundantes).
- Las imágenes decorativas deben tener
alt=""
para suprimir la salida del lector de pantalla. - En imágenes de texto, repite el mismo texto en
alt
. Para pasajes largos, mueve el texto al cuerpo. - Cambia
lang
por idioma y alínea con el idioma principal de la página.
5.3 Captions y <figure>
<figure>
{{-- El <picture> de arriba --}}
<figcaption>{{ $media->meta['caption'] ?? '' }}</figcaption>
</figure>
6. Entrega de video y accesibilidad: subtítulos, controles y autoplay
6.1 Embed básico
<video
controls
preload="metadata"
width="640" height="360"
poster="{{ $posterUrl }}"
style="max-width:100%"
>
<source src="{{ $h264Url }}" type="video/mp4">
<source src="{{ $webmUrl }}" type="video/webm">
<track kind="captions" src="{{ $vttJa }}" srclang="ja" label="日本語" default>
<track kind="captions" src="{{ $vttEn }}" srclang="en" label="English">
{{-- Si tienes descripciones de audio, añade también kind="descriptions" --}}
Your browser does not support the video tag.
</video>
<p class="sr-only" id="video-transcript">{{ $transcriptText }}</p>
Puntos de diseño:
- Habilita
controls
y evita autoplay (si es necesario, usamuted
y asegúrate de acción del usuario). - Proporciona subtítulos con WebVTT, con múltiples
<track>
por idioma. - Para contenido largo, ofrece una transcripción separada.
- En móviles, ofrece bitrate más bajo y deja
preload
enmetadata
.
6.2 Respetar prefers-reduced-motion
@media (prefers-reduced-motion: reduce) {
.hero-video { display:none; } /* p. ej., muestra una imagen poster en su lugar */
}
6.3 Streaming y protección
- Para HLS/DASH, usa entrega segmentada más URLs firmadas de corta duración.
- Para contenido de pago, protege con capas como restricciones de dominio/DRM, y verifica derechos de visualización vía API.
7. Provisión de PDFs/documentos: política de distribución y alternativas
- Asegura que la información importante también exista en HTML, no solo en PDF.
- Los PDF deben estar etiquetados (estructura de encabezados, texto alternativo) e incluir texto real (evita PDFs solo-imagen).
- Para descargas, añade
Content-Disposition: attachment; filename*=UTF-8''...
para evitar nombres corruptos. - Para PDFs largos, incluye resumen y tabla de contenidos, y permite captar la visión general desde una página HTML.
8. Entrega de descarga: firmada, Range y cabeceras
8.1 Ruta firmada
Route::get('/files/{id}', [FileController::class, 'show'])
->middleware('signed')->name('files.show');
public function show(Request $req, int $id) {
$m = Media::findOrFail($id);
$this->authorize('view', $m); // Autorización
$stream = Storage::disk($m->disk)->readStream($m->path);
return response()->stream(function() use ($stream){
fpassthru($stream);
}, 200, [
'Content-Type' => $m->mime,
'Content-Length' => $m->size,
'Content-Disposition' => 'inline; filename="'.basename($m->path).'"',
'Cache-Control' => 'private, max-age=600',
]);
}
8.2 Soporte de Range (archivos grandes)
Para video/audio, es común colocar un proxy/CDN que soporte Range. Si lo haces en la app, implementa Accept-Ranges
y respuestas parciales (206). (Omitido aquí por complejidad.)
9. Diseño de CDN y caché
- Incluye un hash de contenido en nombres de archivo (p. ej.,
hero.5f2a9c.avif
) para caché a largo plazo. - Para generación dinámica, usa
max-age
de 1–10 minutos y ETag. - Considera claves de caché basadas en cabeceras en el CDN (p. ej.,
Accept
/Accept-Language
). - Para invalidación, es más seguro distribuir nuevas URLs (actualiza el hash).
10. UX de administración: barandillas de seguridad para editores
- Haz obligatorio el alt text en cada subida (permitir excepción explícita para decorativas).
- Proporciona campos para “Caption” y “Contexto”, y guía para no duplicar descripciones del artículo.
- Al subir videos, coloca campos de subtítulos junto a la subida y avisa si faltan.
- Usa indicadores de estado independientes del color (iconos + texto) para mostrar público/privado/pendiente.
11. Operaciones: logs, monitorización y ciclo de vida
- Registra logs de auditoría para guardar/ver/eliminar (quién/cuándo/qué).
- Usa reglas de ciclo de vida del bucket para auto-eliminar derivados antiguos.
- Reintenta jobs de derivados fallidos y alerta si superan umbrales.
- Visualiza ratio de errores del CDN, hit rate en edge y volumen transferido para optimizar costes.
12. Testing: Feature/Dusk/Accesibilidad
12.1 Feature (subida/descarga)
public function test_upload_and_signed_download(): void
{
Storage::fake('s3');
Sanctum::actingAs(User::factory()->create());
$resp = $this->postJson('/api/media', [
'image' => UploadedFile::fake()->image('photo.jpg', 800, 600)
])->assertCreated();
$mediaId = $resp->json('id');
$url = URL::temporarySignedRoute('files.show', now()->addMinutes(5), ['id'=>$mediaId]);
$this->get($url)->assertOk()->assertHeader('Content-Type','image/jpeg');
}
12.2 Dusk (UI/lector de pantalla)
- Las imágenes tienen
alt
configurado. - Los videos tienen
track[kind="captions"]
. - Se especifican ancho/alto de imagen y no ocurre CLS.
loading="lazy"
desencadena lazy loading (verificar desplazando).
13. Ejemplo: implementación mínima de API de medios (extracto)
13.1 Rutas
Route::middleware(['auth:sanctum'])->group(function(){
Route::post('/api/media', [MediaController::class,'store']);
Route::patch('/api/media/{media}', [MediaController::class,'update']);
});
13.2 Controller
class MediaController extends Controller
{
public function store(Request $r)
{
$r->validate(['file'=>['required','file','max:8192']]);
$file = $r->file('file');
// Validación de MIME
$finfo = finfo_open(FILEINFO_MIME_TYPE);
abort_unless(in_array(finfo_file($finfo, $file->getRealPath()), ['image/jpeg','image/png','image/webp','application/pdf']), 422);
$path = Storage::disk('s3')->putFile('uploads', $file, 'private');
$m = Media::create([
'disk'=>'s3','path'=>$path,'mime'=>$file->getMimeType(),'size'=>$file->getSize(),
'meta'=>['alt'=>'','caption'=>'','lang'=>app()->getLocale()],
]);
dispatch(new GenerateImageVariants($m->id))->onQueue('media');
return response()->json(['id'=>$m->id,'url'=>Storage::url($path)], 201);
}
public function update(Request $r, Media $media)
{
$this->authorize('update', $media);
$media->update(['meta'=>array_merge($media->meta ?? [], $r->input('meta',[]))]);
return response()->json(['ok'=>true]);
}
}
14. Consejos prácticos de casos reales
- Imágenes hero: Usa
<picture>
con AVIF/WEBP/JPEG y superpone texto como HTML (evita texto horneado en la imagen). - Grids de miniaturas: Crea derivados recortados cuadrados y construye un grid sin CLS.
- Imágenes de producto: El zoom debe ser accesible por teclado (Tab para enfoque → Enter/Espacio para abrir, Esc para cerrar).
- Videos tutoriales: Muestra aviso de volumen al inicio y proporciona subtítulos y transcripción.
- PNG transparentes ligeros: Define un color de fondo × combinado con WebP/AVIF para reducir mucho el tamaño.
- Responsabilidad por la descripción: Implementa mínimos/validación para alt text (permite vacío solo cuando se marca decorativa).
15. Checklist (para distribución)
Storage/Entrega
- [ ] Config de storage (S3/CDN) y uso de
temporaryUrl
- [ ] URLs firmadas, autorización, logs de auditoría
- [ ] Estrategia para Range/archivos grandes (CDN/proxy)
Optimización de imágenes
- [ ] Generar variantes (por ancho/formato)
- [ ]
<picture>
consrcset
/sizes
,loading="lazy"
,decoding="async"
- [ ] Ancho/alto explícitos para suprimir CLS
- [ ] Eliminar EXIF/geotags
Accesibilidad
- [ ] Hacer
alt
obligatorio (vacío para decorativas) - [ ] Usar
<figure><figcaption>
- [ ] Video con
controls
, subtítulos (VTT), transcripción, sin autoplay - [ ] Respetar
prefers-reduced-motion
Seguridad
- [ ] Validación de MIME/extensión, límites de tamaño
- [ ] Escaneo de virus y cuarentena
- [ ]
Content-Disposition
y estrategia de caché
Operaciones
- [ ] Reintentar jobs fallidos y notificar
- [ ] Ciclo de vida de bucket/visibilidad de costes
- [ ] Flujo de revisión para alt text y subtítulos
16. Errores comunes y cómo evitarlos
- Almacenar originales públicamente → riesgo de reutilización/fuga. Usa originales privados + solo derivados públicos.
alt
copiado del título → sin contexto. Escribe según el propósito de la imagen.- Texto incrustado en imágenes → no traducible. Superpone texto HTML en su lugar.
- Autoplay de videos con audio → sorpresa/carga. Predetermina inicio por usuario + subtítulos.
- Falta de width/height en
<img>
→ CLS. Fija dimensiones o especifica aspect-ratio. - Ignorar caché → auge en costes de ancho de banda. Nombres con hash + caché a largo plazo.
17. Conclusión
- Construye un flujo sobre Storage y S3/CDN: almacena en privado → genera derivados → entrega públicamente.
- Para imágenes, usa
<picture>
ysrcset
, lazy loading y dimensiones explícitas para un render estable y ligero. - Para videos, proporciona
controls
, subtítulos y transcripción, y evita autoplay para una experiencia comprensible para cualquiera. - Haz que alt text y captions sean obligatorios en el flujo editorial; usa alt vacío para decorativas.
- Incorpora seguridad (MIME/URLs firmadas/escaneo) y operaciones (logs de auditoría/reintentos/ciclo de vida) desde el día uno.
Los medios son poder para “comunicar”. Sobre una base de rendimiento y seguridad, hagamos crecer una expresión comprensible para todos como estándar de equipo.
Referencias
- Laravel Official
- Optimización y entrega de imágenes
- Accesibilidad
- Seguridad/HTTP