【現場完全ガイド】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>
。 - 長い内容は**トランスクリプト(文字起こし)**を別途提供。
- モバイル回線では低ビットレートを提供し、
preload
はmetadata
程度に。
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
/sizes
、loading="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/スキャン)と運用(監査ログ/失敗再試行/ライフサイクル)を最初から組み込む。
メディアは「伝える力」そのものです。性能と安全を土台に、だれにとっても理解しやすい表現を、チームの標準として育てていきましょう。
参考リンク
- Laravel 公式
- 画像最適化・配信
- アクセシビリティ
- セキュリティ/HTTP