php elephant sticker
Photo by RealToughCandy.com on Pexels.com
目次

【現場完全ガイド】Laravelで実装するメディア管理:画像・動画・ドキュメントの保存/最適化/CDN配信とアクセシブルな代替テキスト・字幕・キャプション

この記事で学べること(要点)

  • Filesystem(Storage)の設計と、S3 など外部ストレージへの安全な保存
  • 画像の最適化(リサイズ・WebP/AVIF・遅延読込・srcset/sizes)とキャッシュ制御
  • 署名付きURL・Content-Disposition・MIME検証・ウイルススキャンなどのセキュリティ
  • 動画のストリーミング配信、字幕(WebVTT)・トランスクリプト・prefers-reduced-motion への配慮
  • PDF/Office ドキュメントの配布ポリシーと、要約・代替コンテンツの提供
  • アクセシブルな代替テキスト・キャプション設計、編集フローの仕組みづくり
  • 運用・監視・テスト(Dusk/Feature)とチェックリスト

想定読者(だれが得をする?)

  • Laravel 初〜中級エンジニア:画像やファイルを安全に扱い、パフォーマンスとアクセシビリティを同時に高めたい方
  • 受託/自社SaaSのテックリード:S3/CDN 前提の拡張可能なメディア設計を標準化したい方
  • デザイナー/コンテンツ編集者:代替テキスト・キャプション・字幕を迷いなく記述できる運用を整えたい方
  • QA/アクセシビリティ担当:読み上げ・キーボード操作・色に依存しない表示をメディアにも適用したい方

1. はじめに:メディア機能は「性能×安全×理解しやすさ」の三本柱

メディア(画像・動画・PDF など)はユーザー体験の要ですが、単に「表示できる」だけでは足りません。

  • 性能:軽いファイル、適切なサイズ、キャッシュ、CDN
  • 安全:MIME/拡張子検証、ウイルススキャン、署名付きURL、権限
  • 理解しやすさ:代替テキスト、字幕、キャプション、トランスクリプト、コントロールの操作性

Laravel は Storage/Flysystem を核に、認可・署名URL・レスポンス制御まで一貫して実装できます。この記事では、実務でそのまま使えるコードと運用の型を提示します。


2. 保存先の設計:Storage(Filesystem)とS3/ローカルの使い分け

2.1 基本設定

config/filesystems.php でディスク(保存先)を定義します。ローカルと S3 を併用し、用途で使い分けます。

// .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 // CDN 経由の公開URL(任意)
// 保存
$path = Storage::disk('s3')->putFile('uploads/images', $request->file('image'), 'private');

// 公開URL(CDN を設定していれば CDN 経由)
$url = Storage::disk('s3')->url($path);

// 一時的に公開(10分)
$tmpUrl = Storage::disk('s3')->temporaryUrl($path, now()->addMinutes(10), [
    'ResponseContentDisposition' => 'inline',
]);

設計の要点:

  • 公開してよいものは public バケットまたはCDN 経由で配信。ダウンロード専用は Content-Disposition: attachment を設定。
  • 原則は非公開で保存し、ダウンロード時のみ署名付きURLを発行。
  • マルチリージョン/CNAME(CDN)を使う場合は、AWS_URL を利用。

2.2 メタデータの永続化

データベースでメディアのメタを管理し、再生成や言語別キャプションに対応します。

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();   // 'thumb','webp','avif' 等
  $t->json('meta')->nullable();       // 'alt','caption','lang','transcript'
  $t->foreignId('user_id')->nullable();
  $t->timestamps();
});

3. アップロードのセキュリティ:検証・サニタイズ・スキャン

3.1 バリデーション

$request->validate([
  'image' => ['required','file','mimetypes:image/jpeg,image/png,image/webp','max:5120'], // 5MB
]);
  • クライアント側の accept は補助に過ぎず、サーバーで MIME を必ず検証
  • max は KB 単位。サイズ上限を明示し、UI でも告知。

3.2 EXIF・ジオタグの扱い

アップロード時にEXIF 情報を削除し、位置情報の漏洩を防ぎます。画像処理ライブラリ(Intervention Image 等)でストリッピング可能。

3.3 ウイルススキャン

外部サービスや ClamAV を使い、アップロード直後に非同期スキャンしましょう(ジョブキューで実行)。陽性の場合は隔離し、ユーザーに安全な文面で通知。


4. 画像最適化:派生生成(リサイズ/変換)とレスポンシブ配信

4.1 バリアント生成(ジョブ)

オリジナルを非公開で保存し、派生(サムネイル・中/大・WebP/AVIF)をキューで生成します。

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); // 例: ['w320','w640','w1280','webp','avif']
    $media->update(['variants' => $variants]);
  }
}

サービス例(概念):

class ImageService {
  public function makeVariants(Media $m): array {
    $src = Storage::disk($m->disk)->get($m->path);
    // 各サイズにリサイズ、JPEG品質調整、WebP/AVIF 変換 etc.
    // 保存後、キー→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<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>

実装の要点:

  • 幅・高さを明示して CLS(レイアウトシフト)を抑制。
  • loading="lazy"decoding="async" で体感速度を向上。
  • <picture> で AVIF/WebP を優先し、非対応環境は JPEG/PNG にフォールバック。

4.3 キャッシュ制御と ETag

CDN 経由で配信する場合は、ビルド生成時に内容ハッシュをファイル名に含め、Cache-Control: public, max-age=31536000, immutable を付与。動的レスポンスにする場合は ETag/Last-Modified を活用。


5. 代替テキスト・キャプション:編集者が迷わない仕組み

5.1 入力UIと保存

<x-form.input id="alt" name="meta[alt]" label="代替テキスト" help="画像が表示できない場合や読み上げで使用されます。" />
<x-form.input id="caption" name="meta" label="キャプション(任意)" />
<x-form.input id="lang" name="meta[lang]" label="言語コード" help="例: ja, en" />

保存時は meta に統合:

$media->update([
  'meta' => [
    'alt'     => $request->input('meta.alt'),
    'caption' => $request->input('meta.caption'),
    'lang'    => $request->input('meta.lang','ja'),
  ],
]);

5.2 良い代替テキストの指針

  • 画像の目的を説明(「青空の下の店舗外観」など)。
  • 「画像」「写真」などの単語は不要(冗長)。
  • 装飾画像は空 alt="" にして読み上げを抑制
  • テキスト画像は同じ内容alt に。長文は本文へ。
  • 言語ごとに lang を切替え、ページの主言語と一致させる。

5.3 キャプションと <figure>

<figure>
  {{-- 上述の <picture> --}}
  <figcaption>{{ $media->meta['caption'] ?? '' }}</figcaption>
</figure>

6. 動画の配信とアクセシビリティ:字幕・操作・自動再生

6.1 基本の埋め込み

<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">
  {{-- 説明音声があれば kind="descriptions" も --}}
  お使いのブラウザは動画再生に対応していません。
</video>
<p class="sr-only" id="video-transcript">{{ $transcriptText }}</p>

設計の要点:

  • controls を有効化し、自動再生を避ける(必要な場合は muted かつユーザー起点)。
  • 字幕は WebVTT を用意し、言語別に複数 <track>
  • 長い内容は**トランスクリプト(文字起こし)**を別途提供。
  • モバイル回線では低ビットレートを提供し、preloadmetadata 程度に。

6.2 prefers-reduced-motion の尊重

@media (prefers-reduced-motion: reduce) {
  .hero-video { display:none; } /* 代わりにポスター画像を表示するなど */
}

6.3 ストリーミングと保護

  • HLS/DASH を使う場合はセグメント配信短寿命の署名URL
  • 有料コンテンツはドメイン制限/DRM等のレイヤで保護し、API 経由で視聴権を確認。

7. PDF/ドキュメントの提供:配布方針と代替

  • 重要情報はHTML でも提供し、PDF のみにならないようにする。
  • PDF はタグ付き(見出し構造・代替テキスト)にし、文字はテキストデータで埋める(画像化しない)。
  • ダウンロードは Content-Disposition: attachment; filename*=UTF-8''... を付与し、ファイル名の文字化けを防止。
  • 長文 PDF には要約目次を付け、HTML ページから概要が把握できるようにする。

8. ダウンロード配信:署名付き・Range・ヘッダー

8.1 署名付きルート

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); // 認可
  $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 Range 対応(大容量)

動画/音声は Range リクエスト対応のプロキシ/CDN を挟むのが一般的。アプリで行う場合は Accept-Ranges と部分レスポンス(206)を実装(難易度高のため詳細は割愛)。


9. CDN とキャッシュの設計

  • ファイル名に内容ハッシュ(例:hero.5f2a9c.avif)を含め長期キャッシュ
  • 動的生成なら 1〜10 分程度の max-age と ETag。
  • CDN でヘッダベースのキャッシュキーAccept / Accept-Language)に注意。
  • 失効は新しいURLを配布する方針が安全(ハッシュ更新)。

10. 管理画面のUX:編集者のための安全ガード

  • 画像をアップロードするたびに代替テキストを必須に(装飾指定時は例外)。
  • 「キャプション」「文脈」入力欄を設け、記事側の説明と重複しないようガイド。
  • 動画アップ時は字幕ファイルのアップロード欄を並置し、未設定なら警告表示。
  • 色に依存しないステータス(アイコン+テキスト)で公開/非公開/承認待ちを明示。

11. 運用:ログ・監視・ライフサイクル

  • 保存・閲覧・削除の監査ログを記録(誰が/いつ/何を)。
  • バケットのライフサイクルで古い派生ファイルの自動削除を設定。
  • 失敗した派生生成ジョブは再試行し、しきい値超過でアラート。
  • CDN のエラー率・エッジキャッシュ率・転送量を可視化してコスト最適化。

12. テスト:Feature/Dusk/アクセシビリティ

12.1 Feature(アップロード/ダウンロード)

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/読み上げ)

  • 画像に alt が設定されている。
  • 動画に track[kind="captions"] がある。
  • 画像の幅・高さが指定され、CLS が発生しない。
  • loading="lazy" で遅延読込が働く(スクロールで確認)。

13. サンプル:メディアAPIの最小実装(抜粋)

13.1 ルート

Route::middleware(['auth:sanctum'])->group(function(){
  Route::post('/api/media', [MediaController::class,'store']);
  Route::patch('/api/media/{media}', [MediaController::class,'update']);
});

13.2 コントローラ

class MediaController extends Controller
{
  public function store(Request $r)
  {
    $r->validate(['file'=>['required','file','max:8192']]);
    $file = $r->file('file');

    // 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. 事例ベースの実践Tips

  • ヒーロー画像:AVIF/WEBP/JP(E)G の <picture>テキストは HTML として重ねる(画像内文字は避ける)。
  • サムネイル一覧:正方形トリミングを派生で作成し、CLS なしのグリッド。
  • 商品画像:ズームはキーボード操作に対応(Tab 到達→Enter/Space で拡大、Esc で閉じる)。
  • チュートリアル動画:冒頭に音量が出る旨を表示し、字幕とトランスクリプトを用意。
  • 透過PNGの軽量化:背景色の決定 × WebP/AVIF の併用で大幅縮小。
  • 画像の説明責任:代替テキストの最小文字数/チェックを実装(空許可時は装飾指定が必要)。

15. チェックリスト(配布用)

保存/配信

  • [ ] Storage 設定(S3/CDN)と temporaryUrl の活用
  • [ ] 署名付きURL・認可・監査ログ
  • [ ] Range/大容量対応の方針(CDN/プロキシ)

画像最適化

  • [ ] バリアント生成(幅別/フォーマット別)
  • [ ] <picture>srcset/sizesloading="lazy"decoding="async"
  • [ ] 幅・高さの明記と CLS 抑制
  • [ ] EXIF/ジオタグの除去

アクセシビリティ

  • [ ] alt の必須化(装飾は空)
  • [ ] <figure><figcaption> の活用
  • [ ] 動画の controls、字幕(VTT)、トランスクリプト、オートプレイ抑制
  • [ ] prefers-reduced-motion の尊重

セキュリティ

  • [ ] MIME/拡張子検証、サイズ上限
  • [ ] ウイルススキャンと隔離
  • [ ] Content-Disposition とキャッシュ戦略

運用

  • [ ] 失敗ジョブの再試行と通知
  • [ ] バケットライフサイクル/コスト可視化
  • [ ] 代替テキスト・字幕のレビュー運用

16. よくある落とし穴と回避策

  • オリジナルを公開保存 → 流用/流出のリスク。非公開+派生のみ公開に。
  • alt がタイトルのコピペ → 文脈不足。画像の目的に合わせて書く。
  • 画像にテキストを焼き込む → 翻訳できない。HTML テキストで重ねる。
  • 動画を自動再生・音声オン → 驚きや負担。ユーザー起点+字幕を基本。
  • <img> に幅・高さがない → CLS。固定またはアスペクト比を指定。
  • キャッシュ無視の配信 → 帯域コスト増。ハッシュ名+長期キャッシュ

17. まとめ

  • Storage と S3/CDN を基盤に、非公開保存→派生生成→公開配信の流れを確立。
  • 画像は <picture>srcset、遅延読込、幅・高さ指定で軽くて安定した表示へ。
  • 動画は controls、字幕・トランスクリプト、オートプレイ抑制でだれでも理解しやすい体験に。
  • 代替テキストとキャプションは編集フローとして必須化し、装飾画像は空 alt
  • セキュリティ(MIME/署名URL/スキャン)と運用(監査ログ/失敗再試行/ライフサイクル)を最初から組み込む。

メディアは「伝える力」そのものです。性能と安全を土台に、だれにとっても理解しやすい表現を、チームの標準として育てていきましょう。


参考リンク

投稿者 greeden

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)