【PHP】Laravelで画像をリサイズ・変換する方法(Image API)

対象バージョン: Laravel 13.x / PHP 8.3+(laravel/framework v13.39.0 で動作確認)

Laravel 13.x には、アップロードした画像やストレージ上の画像をリサイズ・変換する Image API があります。この記事は、アバター画像を正方形の WebP に揃えたい方や、縦横比を保ったまま縮小したい方向けです。Intervention Image の導入から、cover / scale によるリサイズ、WebP / JPEG への変換、public ディスクへの保存、テストまでを順に説明します。ファイルの受け取りとストレージの基本は【PHP】Laravelでファイルアップロードとストレージを扱う方法で扱っており、この記事はその記事で対象外としたリサイズ・変換を補います。

この記事で分かること

  • Intervention Image のインストールと、画像ドライバ(GD)の設定
  • $request->image() でアップロード画像を読み取る方法
  • cover / scale / orient によるリサイズと向きの補正
  • toWebp / toJpeg / quality / optimize による変換
  • storePublicly / storePubliclyAs での保存
  • ストレージ上の画像を Image::fromStorage や Storage::disk()->image() で扱う方法
  • バリデーションと Feature テスト

前提

Laravel 13.x は PHP 8.3 以上が必要です。Image API は laravel/framework v13.20.0 で追加されました。動作確認は、composer create-project laravel/laravel:^13.0 で新規作成したアプリ(laravel/framework v13.39.0、PHP 8.4、intervention/image 4.4.0、ドライバ gd)で行いました。プロジェクトの作成方法は【PHP】Laravel入門:インストールから簡単なサンプルアプリケーションの作成を参照してください。

public ディスクへの保存と php artisan storage:link による公開は、先のファイルアップロードの記事で説明しています。この記事ではそこを前提に、画像の加工に進みます。

Intervention Image を入れる

Image API を使う前に、Intervention Image をインストールします。公式ドキュメントのコマンドは次のとおりです。

composer require intervention/image:^4.0

動作確認では intervention/image 4.4.0 が入りました。フレームワーク側の illuminate/image は suggest として intervention/image ^4.2 を挙げています。^4.0 で入れた 4.4.0 はどちらも満たします。

PHP には画像ドライバ用の拡張が必要です。既定のドライバは GD なので、環境に GD が入っていることを確認してください(動作確認では php -m に gd が出る状態で進めました)。

ドライバの設定を公開する

画像ドライバの設定ファイルを公開します。

php artisan config:publish images

config/images.php が次の内容で作成されます。

<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Default Image Driver
    |--------------------------------------------------------------------------
    |
    | This option controls the default image processing driver that will be
    | used when manipulating or converting images. This driver is always
    | utilized unless another driver is explicitly specified instead.
    |
    | Supported: "gd", "imagick"
    |
    */

    'default' => env('IMAGE_DRIVER', 'gd'),

];

既定では IMAGE_DRIVER 未設定時に gd が使われます。Imagick 拡張が入っている環境では、.env で IMAGE_DRIVER=imagick を指定して切り替えることもできます。

アップロード用のフォームを用意する

画像を送るフォームには、method="POST"、enctype="multipart/form-data"、@csrf が必要です。resources/views/avatars/create.blade.php の例です。

<form method="POST" action="{{ url('/avatar') }}" enctype="multipart/form-data">
    @csrf
    <label>
        アバター画像
        <input type="file" name="avatar" accept="image/*" required>
    </label>
    <button type="submit">アップロード</button>
</form>

アップロード画像を読み取る

$request->image('フィールド名') で、アップロードされた画像を Illuminate\Image\Image として取得できます。ファイルが無い場合は null です。別の書き方として Image::fromUpload($request->file('avatar')) もあります。

次のルートは、アバターを正方形の WebP に揃えて public ディスクへ保存する例です。routes/web.php を次の内容にします(このあと別ルートも同じファイルへ追記します)。

<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Image;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rules\File;

Route::post('/avatar', function (Request $request) {
    $request->validate([
        'avatar' => ['required', 'image'],
    ]);

    $path = $request->image('avatar')
        ->orient()
        ->cover(400, 400)
        ->toWebp()
        ->quality(80)
        ->storePublicly('avatars', 'public');

    $full = Storage::disk('public')->path($path);
    $size = @getimagesize($full);

    return response()->json([
        'path' => $path,
        'exists' => Storage::disk('public')->exists($path),
        'mime' => $size['mime'] ?? null,
        'width' => $size[0] ?? null,
        'height' => $size[1] ?? null,
    ]);
});

Image のインスタンスはイミュータブルです。各メソッドは新しいインスタンスを返し、変換処理はチェーンの順に進み、エンコードは末尾で一度だけ行われます。

リサイズ: cover と scale

この記事で動作を確認したリサイズは次の2つです。

メソッド 動き(確認結果)
cover(400, 400) 指定サイズを覆うようにリサイズとクロップ。cover 後に toWebp した確認結果では、800×600 の JPEG を渡すと 400×400 の WebP になる
scale(width: 320) 縦横比を保って縮小。640×480 の PNG を渡すと 320×240 になる。拡大はしない
scale(height: 100) 高さ基準で縮小。800×600 を渡すと 133×100 になる

orient() は EXIF の向き情報を反映します。上の /avatar 例では、orient() のあとに cover(400, 400) をつないでいます。

形式の変換と品質

エンコードはチェーンの末尾で指定します。動作確認で使った組み合わせは次のとおりです。

指定 結果
->toWebp()->quality(80) MIME は image/webp
->toJpeg()->quality(85) MIME は image/jpeg、拡張子は jpg
->optimize() WebP、品質の既定値は 70

quality() に渡す値は 1〜100 です。optimize() は形式と品質をまとめて指定するショートカットで、引数なしのときの品質は 70 です(古い説明の 75 ではありません)。

JPEG へ変換してファイル名を指定して保存する例を、同じ routes/web.php に追記します。

Route::post('/avatar-file-rule', function (Request $request) {
    $request->validate([
        'avatar' => ['required', File::image()],
    ]);

    $image = $request->image('avatar')
        ->scale(width: 320)
        ->toJpeg()
        ->quality(85);

    $inspect = [
        'width' => $image->width(),
        'height' => $image->height(),
        'mimeType' => $image->mimeType(),
        'extension' => $image->extension(),
        'dimensions' => $image->dimensions(),
    ];

    $path = $image->storePubliclyAs('avatars', 'scaled-avatar.jpg', 'public');

    return response()->json([
        'path' => $path,
        'inspect' => $inspect,
    ]);
});

width() / height() / mimeType() / extension() / dimensions() は、加工後の結果を返します。上の例では inspect に幅 320、高さ 240、image/jpeg、jpg が入ります。

optimize() だけの例です。

Route::post('/optimize', function (Request $request) {
    $request->validate(['avatar' => ['required', 'image']]);

    $path = $request->image('avatar')
        ->optimize()
        ->storePublicly('avatars', 'public');

    return response()->json(['path' => $path]);
});

800×600 の画像を渡すと、サイズはそのままで WebP として保存されます。

保存: storePublicly / storePubliclyAs

メソッド 役割
storePublicly('avatars', 'public') public ディスクの avatars/ 配下へ、公開可視性で保存。戻り値は保存先パス(例: avatars/<hash>.webp)
storePubliclyAs('avatars', 'scaled-avatar.jpg', 'public') ファイル名を自分で指定して同様に保存

保存に失敗すると false が返ります。公開 URL の出し方や storage:link は、ファイルアップロードの記事を参照してください。

ストレージ上の画像を加工する

すでにディスクにある画像は、Image::fromStorage($path, $disk) または Storage::disk($disk)->image($path) で読み取れます。次のルートを routes/web.php に追記します。実行前に、public ディスクへ fixtures/source.jpg を置いておいてください。

Route::post('/from-storage', function () {
    $path = Image::fromStorage('fixtures/source.jpg', 'public')
        ->cover(200, 200)
        ->toWebp()
        ->quality(70)
        ->storePublicly('avatars', 'public');

    $viaDisk = Storage::disk('public')
        ->image('fixtures/source.jpg')
        ->scale(height: 100)
        ->toPng()
        ->storePubliclyAs('avatars', 'via-disk.png', 'public');

    return response()->json([
        'fromStorage_path' => $path,
        'viaDisk_path' => $viaDisk,
    ]);
});

800×600 の JPEG を渡した場合、fromStorage 側は 200×200 の WebP、viaDisk 側は 133×100 の PNG になります。

routes/web.php の全体

ここまでのルートをまとめた routes/web.php です。

<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Image;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rules\File;

Route::post('/avatar', function (Request $request) {
    $request->validate([
        'avatar' => ['required', 'image'],
    ]);

    $path = $request->image('avatar')
        ->orient()
        ->cover(400, 400)
        ->toWebp()
        ->quality(80)
        ->storePublicly('avatars', 'public');

    $full = Storage::disk('public')->path($path);
    $size = @getimagesize($full);

    return response()->json([
        'path' => $path,
        'exists' => Storage::disk('public')->exists($path),
        'mime' => $size['mime'] ?? null,
        'width' => $size[0] ?? null,
        'height' => $size[1] ?? null,
    ]);
});

Route::post('/avatar-file-rule', function (Request $request) {
    $request->validate([
        'avatar' => ['required', File::image()],
    ]);

    $image = $request->image('avatar')
        ->scale(width: 320)
        ->toJpeg()
        ->quality(85);

    $inspect = [
        'width' => $image->width(),
        'height' => $image->height(),
        'mimeType' => $image->mimeType(),
        'extension' => $image->extension(),
        'dimensions' => $image->dimensions(),
    ];

    $path = $image->storePubliclyAs('avatars', 'scaled-avatar.jpg', 'public');

    return response()->json([
        'path' => $path,
        'inspect' => $inspect,
    ]);
});

Route::post('/optimize', function (Request $request) {
    $request->validate(['avatar' => ['required', 'image']]);

    $path = $request->image('avatar')
        ->optimize()
        ->storePublicly('avatars', 'public');

    return response()->json(['path' => $path]);
});

Route::post('/from-storage', function () {
    $path = Image::fromStorage('fixtures/source.jpg', 'public')
        ->cover(200, 200)
        ->toWebp()
        ->quality(70)
        ->storePublicly('avatars', 'public');

    $viaDisk = Storage::disk('public')
        ->image('fixtures/source.jpg')
        ->scale(height: 100)
        ->toPng()
        ->storePubliclyAs('avatars', 'via-disk.png', 'public');

    return response()->json([
        'fromStorage_path' => $path,
        'viaDisk_path' => $viaDisk,
    ]);
});

バリデーション

アップロードが画像かどうかを確かめるには、次のどちらも使えます。

  • 文字列ルール 'image'(/avatar の例)
  • File::image()(/avatar-file-rule の例)

画像以外(PDF など)を送ると、422 となり「The avatar field must be an image.」というエラーになります。バリデーション全般は【PHP】Laravelでフォームバリデーションを実装する方法を参照してください。

Feature テスト

Feature テストでは UploadedFile::fake()->image() と Storage::fake('public') を使えます。fake()->image() には GD 拡張が必要です。tests/Feature/ImageApiTest.php の例です(動作確認した5件のうち、本文のルートに対応する3件です)。

<?php

namespace Tests\Feature;

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

class ImageApiTest extends TestCase
{
    public function test_request_image_cover_webp_store_publicly(): void
    {
        Storage::fake('public');

        $file = UploadedFile::fake()->image('avatar.jpg', 800, 600);

        $response = $this->post('/avatar', [
            'avatar' => $file,
        ]);

        $response->assertOk();
        $path = $response->json('path');
        $this->assertNotEmpty($path);
        $this->assertTrue(Storage::disk('public')->exists($path));
        $this->assertSame(400, $response->json('width'));
        $this->assertSame(400, $response->json('height'));
        $this->assertSame('image/webp', $response->json('mime'));
    }

    public function test_file_image_rule_scale_jpeg(): void
    {
        Storage::fake('public');

        $file = UploadedFile::fake()->image('photo.png', 640, 480);

        $response = $this->post('/avatar-file-rule', [
            'avatar' => $file,
        ]);

        $response->assertOk();
        $this->assertSame(320, $response->json('inspect.width'));
        $this->assertSame(240, $response->json('inspect.height'));
        $this->assertSame('image/jpeg', $response->json('inspect.mimeType'));
        $this->assertTrue(Storage::disk('public')->exists($response->json('path')));
    }

    public function test_validation_rejects_non_image(): void
    {
        Storage::fake('public');
        $file = UploadedFile::fake()->create('doc.pdf', 100, 'application/pdf');
        $this->post('/avatar', ['avatar' => $file])->assertSessionHasErrors('avatar');
    }
}
php artisan test --filter=ImageApiTest

テストの基本は【PHP】Laravelでテストを実装する方法を参照してください。

処理が重い場合

画像の加工は CPU やメモリを使うことがあります。アップロードのリクエスト内で重い処理をしない方がよい場合は、キューのジョブへ回してください。キューの実装は【PHP】Laravelでキューとジョブを実装する方法とその利用シーンを参照してください。

S3 などのクラウドストレージや CDN、Imagick の詳細設定はこの記事では扱いません。

よくあるつまずき

症状 原因と対処
Image API のメソッドが無い フレームワークが v13.20.0 未満。13.x の新しいパッチへ上げる
intervention/image が無い composer require intervention/image:^4.0 を実行する
GD 関連のエラーや fake()->image() が失敗する PHP に GD 拡張が入っていない。php -m で gd を確認する
保存したファイルにブラウザから届かない public ディスクと storage:link の設定を確認する(ファイルアップロードの記事)
画像以外を送っても通ってしまう 'image' または File::image() のバリデーションを付ける
optimize() の品質が想定と違う 引数なしの既定品質は 70

まとめ

  • Image API は Laravel 13.20.0 以降で使える。Intervention Image は composer require intervention/image:^4.0 で入れる
  • 既定ドライバは GD(IMAGE_DRIVER)。設定は php artisan config:publish images で公開する
  • $request->image('avatar') でアップロード画像を読み取り、orient / cover / scale のあとに toWebp や toJpeg、quality、optimize をつなぐ
  • optimize() の既定品質は 70。storePublicly / storePubliclyAs で public ディスクへ保存できる
  • ディスク上の画像は Image::fromStorage または Storage::disk()->image() で扱える
  • バリデーションは 'image' と File::image() のどちらでもよい。Feature テストの fake()->image() には GD が必要
  • 重い加工はキューへ回す

関連記事

参考リンク

コメントする