Icono del sitio IT&ライフハックブログ|学びと実践のためのアイデア集

[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

php elephant sticker

Photo by RealToughCandy.com on Pexels.com

[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" y decoding="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, usa muted 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 en metadata.

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> con srcset/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> y srcset, 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

Salir de la versión móvil