Laravelのメディア管理実装ガイド:S3保存、画像最適化、動画とPDF配信、アクセシビリティ

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

Laravelでメディア管理を実装するときは、ファイルを保存する処理だけでなく、公開範囲、変換、配信、説明文、削除までを一つの流れとして設計します。

基本形は、原本を非公開で保存し、検証とスキャンを通過したファイルだけを配信対象にし、画像は用途別の派生ファイルを公開する構成です。

この記事では、画像、動画、PDFを扱うための判断順と、Laravelでの実装例を示します。

メディア管理で先に決めること

メディア管理とは、アップロードされたファイルと、その公開範囲、変換後のファイル、代替テキストなどの付随情報をまとめて管理する仕組みです。

実装前に、次の三点を分けて決めます。

判断する対象 決める内容 設計上の狙い
保存 原本と公開用ファイルの保存先、公開範囲、保持期間 非公開にすべき原本を誤って公開しない
処理 MIME検証、ウイルススキャン、画像変換、失敗時の再試行 未検証のファイルを配信せず、重い処理をリクエストから分離する
利用 代替テキスト、字幕、キャプション、ダウンロード権限 利用者と編集者が内容と状態を理解できるようにする

処理の流れは次のように整理できます。

  1. サーバー側で種類とサイズを検証する。
  2. 原本を非公開領域に保存し、データベースへメタデータを記録する。
  3. スキャンと画像変換をキューで実行する。
  4. 検査済みの派生ファイル、または期限付きURLだけを配信する。
  5. 公開後も利用状況、失敗、削除を記録する。

Laravelの認証、キュー、テストを含む全体設計は、PHPとLaravelで業務Webアプリを作る前に決めることも参考になります。

StorageとS3で保存先を分ける

Laravelのディスクは、ローカルファイルシステムやAmazon S3などの保存先を共通のAPIで扱うための設定単位です。

config/filesystems.phpでディスクを定義し、環境ごとの差は環境変数へ寄せます。

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

環境変数の値の末尾に説明を書き込むと設定値として解釈されることがあるため、コメントは別の行に置きます。

アップロード時は、保存先のディスクを明示すると処理の意図が読み取りやすくなります。

$path = Storage::disk('s3')->putFile(
    'uploads/originals',
    $request->file('file'),
    'private'
);

privateで保存した原本に対して、公開URLを前提とするurl()を使う構成にはしません。

非公開ファイルは認可後に期限付きURLを発行し、公開用の派生画像だけをCDNから配信するなど、用途に応じて経路を分けます。

S3を保存先、CloudFrontを配信経路として使う場合の役割は、Amazon S3とCloudFrontの使い分けと連携方法で確認できます。

ファイルと説明情報を一緒に記録する

ストレージにはファイル本体を置き、データベースには保存先、MIMEタイプ、サイズ、処理状態、派生ファイル、説明情報を記録します。

Schema::create('media', function (Blueprint $table) {
    $table->id();
    $table->string('disk')->default('s3');
    $table->string('path');
    $table->string('mime');
    $table->unsignedBigInteger('size');
    $table->string('status')->default('pending');
    $table->json('variants')->nullable();
    $table->json('meta')->nullable();
    $table->foreignId('user_id')->nullable();
    $table->timestamps();
});

statusには、たとえばpending、scanning、ready、rejectedのような状態を保存します。

ファイルが存在することと、公開してよいことを同じ意味にしないための項目です。

アップロード時の検証と隔離

ブラウザのaccept属性は、選択できるファイルを絞るための補助です。

送信内容を信頼する根拠にはならないため、サーバー側でサイズ、内容から推定したMIMEタイプ、利用者が付けた拡張子を確認します。

$request->validate([
    'file' => [
        'required',
        'file',
        'mimetypes:image/jpeg,image/png,image/webp,application/pdf,video/mp4',
        'extensions:jpg,jpeg,png,webp,pdf,mp4',
        'max:8192',
    ],
]);

mimetypesはファイル内容からMIMEタイプを推定して照合しますが、拡張子との一致まで保証する規則ではありません。

一方、extensionsだけでは内容の安全性を判断できません。

両方を許可リストとして使い、その後のスキャンと画像の再エンコードを別工程にします。

原本を公開前の領域に置く

検証を通った直後のファイルも、スキャンが終わるまでは公開しません。

外部スキャンサービスやClamAVを利用する場合は、キュージョブで検査し、合格後にreadyへ進めます。

検査で問題が見つかったファイルは隔離し、管理画面には処理結果だけを表示します。

画像のEXIFには撮影位置が含まれる場合があります。

公開用の派生画像では、表示方向を正したうえで不要なEXIFを削除する方針にします。

原本の保持が必要な業務では、原本まで一律に削除せず、非公開保存とアクセス権限で扱いを分けます。

画像はサイズと形式ごとに派生させる

派生画像とは、一つの原本から表示幅や形式を変えて作る配信用ファイルです。

原本を毎回変換すると応答が遅くなるため、アップロード後のキューで必要な組み合わせを生成します。

class GenerateImageVariants implements ShouldQueue
{
    public function __construct(public int $mediaId) {}

    public function handle(ImageService $images): void
    {
        $media = Media::findOrFail($this->mediaId);
        $variants = $images->makeVariants($media);

        $media->update([
            'variants' => $variants,
            'status' => 'ready',
        ]);
    }
}

派生情報は、幅と形式を区別できる形で保存します。

[
    'jpeg' => [
        '320' => 'https://cdn.example.com/media/42/image-320.jpg',
        '640' => 'https://cdn.example.com/media/42/image-640.jpg',
        '1280' => 'https://cdn.example.com/media/42/image-1280.jpg',
    ],
    'webp' => [
        '320' => 'https://cdn.example.com/media/42/image-320.webp',
        '640' => 'https://cdn.example.com/media/42/image-640.webp',
        '1280' => 'https://cdn.example.com/media/42/image-1280.webp',
    ],
]

形式だけを変えた一枚では、画面幅に応じた転送量の削減ができません。

幅ごとの候補も用意し、srcsetとsizesでブラウザに選ばせます。

picture、srcset、sizesを組み合わせる

@php($variants = $media->variants)

<picture>
    <source
        type="image/webp"
        srcset="{{ $variants['webp']['320'] }} 320w,
                {{ $variants['webp']['640'] }} 640w,
                {{ $variants['webp']['1280'] }} 1280w"
        sizes="(max-width: 640px) 90vw, 640px"
    >
    <img
        src="{{ $variants['jpeg']['640'] }}"
        srcset="{{ $variants['jpeg']['320'] }} 320w,
                {{ $variants['jpeg']['640'] }} 640w,
                {{ $variants['jpeg']['1280'] }} 1280w"
        sizes="(max-width: 640px) 90vw, 640px"
        alt="{{ $media->meta['alt'] ?? '' }}"
        width="640"
        height="400"
        loading="lazy"
        decoding="async"
    >
</picture>
  • srcsetは候補画像と幅を示します。
  • sizesはレイアウト上で画像が占める幅をブラウザへ伝えます。
  • widthとheightは表示前に縦横比を確保し、レイアウトのずれを抑えます。
  • loading="lazy"は主に画面外の画像へ使います。最初に見せる主画像まで機械的に遅延させないようにします。

AVIFも同じ考え方で追加できますが、変換環境、画質、処理時間を確認してから採用します。

代替テキストは画像の役割から決める

代替テキストは、画像が伝える情報や機能を文字で提供するものです。

見た目を列挙するのではなく、そのページで画像が何のために置かれているかを基準に書きます。

画像の役割 入力する内容 例
情報を伝える画像 本文理解に必要な情報を簡潔に書く 「駅北口から店舗までの経路図」
操作に使う画像 見た目ではなく操作結果を書く 「検索する」
装飾画像 空のalt=""を設定する 背景の模様
複雑な図 短い代替テキストに加え、本文や近接リンクで詳しい説明を提供する 売上推移グラフとデータ表

「画像」「写真」と毎回書く必要はありません。

ただし、写真であること自体が判断材料になる場合は、その情報を省きません。

用途ごとの例は、画像の代替テキストの目的別の書き方で詳しく整理しています。

編集画面で文脈も保存する

代替テキストとキャプションは役割が異なります。

キャプションはすべての利用者に見える補足であり、出典や撮影条件などを示す場所です。

編集画面には、代替テキスト、キャプション、言語、装飾画像かどうかを別項目として用意します。

<x-form.input
    id="alt"
    name="meta[alt]"
    label="代替テキスト"
    help="このページで画像が伝える情報または機能を書きます。"
/>

<x-form.checkbox
    id="decorative"
    name="meta[decorative]"
    label="装飾画像として扱う"
/>

<x-form.input
    id="caption"
    name="meta"
    label="画面に表示するキャプション"
/>

代替テキストを一律に必須入力にすると、装飾画像にも不要な説明が入りやすくなります。

空欄を許す場合は装飾指定を明示的に選ばせ、未入力との区別を保存します。

<figure>
    {{-- picture要素をここに出力 --}}

    @if(filled($media->meta['caption'] ?? null))
        <figcaption>{{ $media->meta['caption'] }}</figcaption>
    @endif
</figure>

動画には操作、字幕、文字起こしを用意する

動画はcontrolsを有効にし、利用者が再生、停止、音量を操作できるようにします。

自動再生は避け、読み込みもpreload="metadata"程度から始めると、閲覧者が再生を選ぶ前の通信を抑えられます。

<video
    controls
    preload="metadata"
    width="640"
    height="360"
    poster="{{ $posterUrl }}"
>
    <source src="{{ $h264Url }}" type="video/mp4">
    <source src="{{ $webmUrl }}" type="video/webm">
    <track
        kind="captions"
        src="{{ $vttJa }}"
        srclang="ja"
        label="日本語字幕"
        default
    >
    お使いのブラウザは動画再生に対応していません。
</video>

<p><a href="{{ $transcriptUrl }}">動画の文字起こしを読む</a></p>

字幕には会話だけでなく、内容を理解するために必要な効果音や話者の区別も含めます。

文字起こしは字幕ファイルを置き換えるものではありません。

検索、引用、動画を再生しにくい状況での確認にも使えるため、支援技術の利用者だけに隠さず、見えるリンクまたは展開できる本文として提供します。

映像にだけ含まれる情報がある場合は、音声解説または視覚情報を含む文字説明も検討します。

prefers-reduced-motionはアニメーションなどの動きを減らしたい利用者の設定です。

この設定だけで動画のアクセシビリティが満たされるわけではないため、自動再生を避けることと、停止操作を提供することを先に確認します。

PDFと文書はHTMLの入口を残す

PDFやOffice文書だけで情報を提供すると、内容を確認する前にダウンロードや対応アプリが必要になります。

掲載ページには、文書の目的、対象者、要点、ファイル形式、容量をHTMLで示します。

  • 本文で伝えられる主要情報はHTMLにも掲載する。
  • PDFには見出し構造、読み上げ順、文書言語、代替テキストを設定する。
  • スキャン画像だけのPDFにせず、文字を選択できる状態を確認する。
  • 長い文書には概要と目次を付ける。

制作から公開前確認までの手順は、PDF、Word、PowerPoint、EPUBのアクセシビリティ実務ガイドを参照してください。

非公開ファイルは認可後に配信する

Laravelの署名付きルートは、URLのクエリが改変されていないことと有効期限を確認する仕組みです。

署名が有効でも、その利用者が対象ファイルを閲覧できるとは限らないため、Policyなどによる認可を別に実行します。

use Illuminate\Support\Facades\Gate;

Route::get('/files/{media}', [FileController::class, 'show'])
    ->middleware(['auth', 'signed'])
    ->name('files.show');

public function show(Media $media)
{
    Gate::authorize('view', $media);
    abort_unless($media->status === 'ready', 404);

    $url = Storage::disk($media->disk)->temporaryUrl(
        $media->path,
        now()->addMinutes(10),
        ['ResponseContentDisposition' => 'attachment']
    );

    return redirect()->away($url);
}

S3の署名付きURLは、期限内だけ対象オブジェクトへアクセスできるURLです。

URLを受け取った人が期限内に使える点は変わらないため、有効期間を用途に合わせ、ログや画面へ不要に残さないようにします。

動画や大容量ファイルのRangeリクエストは、アプリケーションで独自実装する前に、オブジェクトストレージやCDNへ任せられるかを確認します。

部分レスポンス、権限切れ、再開、モバイル回線での挙動までテスト対象にします。

CDNのキャッシュは公開範囲と更新方法に合わせる

長期キャッシュは、内容が変わるとURLも変わる派生ファイルに向いています。

たとえば内容ハッシュを含むhero.5f2a9c.webpなら、更新時に新しいURLを生成できます。

この条件があるファイルに限って、長いmax-ageとimmutableを設定します。

同じURLの内容を上書きする運用で長期キャッシュを使うと、古い画像が残ります。

非公開ファイルや利用者ごとに認可するレスポンスへ、公開用のキャッシュ設定を流用しないようにします。

画像形式をAcceptヘッダーで出し分ける場合は、CDNのキャッシュキーも形式を区別する必要があります。

<picture>で形式別URLを明示できるなら、キャッシュ構成を単純に保ちやすくなります。

編集画面に安全な運用を組み込む

公開品質は、保存時のバリデーションだけでは維持できません。

編集者がファイルの状態と不足項目を判断できる管理画面にします。

  • アップロード前に許可形式とサイズ上限を表示する。
  • スキャン中、変換中、公開可能、隔離を文字とアイコンで示す。
  • 色だけで状態を区別しない。
  • 装飾画像以外で代替テキストが空なら、公開前に確認を求める。
  • 動画には字幕と文字起こしの登録欄を並べる。
  • 差し替え時に、古い派生ファイルとCDNキャッシュをどう扱うかを明示する。

保存、公開、閲覧、差し替え、削除の監査ログには、実行者、対象、時刻、結果を記録します。

原本と派生ファイルで保持期間が異なる場合は、削除ジョブにも同じ区別を反映します。

自動テストと人による確認を分ける

Featureテストでは、検証、保存、認可、期限付きURL、ジョブ投入、失敗時の状態を確認します。

public function test_image_is_saved_with_pending_status(): void
{
    Storage::fake('s3');
    $user = User::factory()->create();

    $response = $this->actingAs($user)->postJson('/api/media', [
        'file' => UploadedFile::fake()->image('photo.jpg', 800, 600),
    ]);

    $response->assertCreated();

    $media = Media::findOrFail($response->json('id'));
    Storage::disk('s3')->assertExists($media->path);
    $this->assertSame('pending', $media->status);
}

ブラウザテストでは、次の機械判定しやすい項目を確認します。

  • 情報画像にalt属性があり、装飾画像はalt=""になっている。
  • 動画に操作部と字幕トラックがある。
  • 管理画面をキーボードで操作でき、フォーカス位置を視認できる。
  • エラーが色だけで示されず、対象入力と関連付けられている。

alt属性が存在するだけでは、説明が適切かどうかは分かりません。

Duskなどのブラウザテストも、スクリーンリーダーで理解できることを保証しません。

代替テキストの意味、字幕の正確さ、読み上げ順、キーボード操作は、人による確認を残します。

公開前チェックリスト

保存とセキュリティ

  • 原本は非公開で保存されている。
  • MIMEタイプ、拡張子、サイズをサーバー側で検証している。
  • スキャン完了前のファイルを配信していない。
  • 署名付きURLの前に利用者の認可を確認している。

画像と動画

  • 用途に合う幅と形式の派生画像がある。
  • srcset、sizes、幅、高さを設定している。
  • 画像の役割に合う代替テキスト、または空のaltがある。
  • 動画に操作部、字幕、見える文字起こしを用意している。

配信と運用

  • 長期キャッシュはURLが変わる公開ファイルだけに使っている。
  • 大容量ファイルのRange配信と権限切れを実機で確認している。
  • 失敗ジョブの再試行回数と通知先を決めている。
  • 差し替え、保持期間、削除、監査ログの運用が決まっている。

実装の着地点

Laravelのメディア管理は、「非公開の原本」「検査済みの配信物」「説明情報」を分けると整理できます。

Storageとキューは処理を支える土台であり、公開可否は認可と処理状態で判断します。

画像には表示幅に合う派生ファイルと代替テキスト、動画には操作部、字幕、文字起こしを用意します。

最後に自動テストと人による確認を組み合わせれば、性能、安全性、情報の伝わりやすさを同じ運用の中で改善できます。

参考資料

投稿者 greeden

コメントを残す

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

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