DockerでLaravel REST APIを構築する:ゼロから本番環境まで(2024年版)

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

クイックスタート — 5分でLaravelを起動する

6ヶ月前、Laravelを使ったSaaSプロダクトを本番環境にリリースしました。それまでは少し躊躇していました——PHPは「いずれ卒業する言語」というイメージがあったからです。しかし、実際の負荷で動かしてみると、その疑念はすっかり消えました。今回は、実際に機能した構成をそのままご紹介します。

まずDockerから始めて、開発環境を最初から本番と同じ構成にしましょう:

# ComposerでLaravelをインストール
composer create-project laravel/laravel my-api
cd my-api

# Laravelインストーラーを使う場合
composer global require laravel/installer
laravel new my-api --git

コントローラを1行も書く前に、プロジェクトルートにこの docker-compose.yml を置きます:

version: '3.8'
services:
  app:
    build: .
    ports:
      - "8000:8000"
    volumes:
      - .:/var/www/html
    depends_on:
      - db
    environment:
      DB_HOST: db
      DB_DATABASE: myapp
      DB_USERNAME: root
      DB_PASSWORD: secret

  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: secret
      MYSQL_DATABASE: myapp
    volumes:
      - db_data:/var/lib/mysql

  redis:
    image: redis:alpine

volumes:
  db_data:

最小限の Dockerfile を追加します:

FROM php:8.2-fpm-alpine

RUN apk add --no-cache \
    nginx \
    curl \
    git \
    && docker-php-ext-install pdo pdo_mysql redis

COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

WORKDIR /var/www/html
COPY . .
RUN composer install --no-dev --optimize-autoloader

EXPOSE 8000
CMD ["php", "artisan", "serve", "--host=0.0.0.0", "--port=8000"]

起動しましょう:

docker-compose up -d
docker-compose exec app php artisan migrate

これで、MySQLとRedisを備えたLaravelアプリが動いています——私が本番環境で使っているのとまったく同じスタックです。

詳解 — RESTful APIの構築

リソースコントローラとルーティング

Laravelのリソースルーティングはボイラープレートを一掃します。コマンド一つで完全なCRUDのひな形が生成されます:

php artisan make:model Post -mcr
# -m = マイグレーション, -c = コントローラ, -r = リソースコントローラ

routes/api.php にリソースを登録します:

<?php
use App\Http\Controllers\PostController;

Route::apiResource('posts', PostController::class);
// 生成されるルート: GET /posts, POST /posts, GET /posts/{id},
//            PUT /posts/{id}, DELETE /posts/{id}

app/Http/Controllers/PostController.php のページネーション付きindexメソッドは次のようになります:

public function index()
{
    $posts = Post::with('author')
        ->latest()
        ->paginate(15);

    return response()->json([
        'data' => $posts->items(),
        'meta' => [
            'current_page' => $posts->currentPage(),
            'last_page'    => $posts->lastPage(),
            'per_page'     => $posts->perPage(),
            'total'        => $posts->total(),
        ]
    ]);
}

Laravel Sanctumによるユーザー認証

トークンベースのAPI認証には、Sanctumが最適です——Passportより軽量で、OAuthの複雑さもありません:

composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate

UserモデルにHasApiTokensトレイトを追加します:

<?php
namespace App\Models;

use Laravel\Sanctum\HasApiTokens;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    use HasApiTokens;
}

AuthControllerを作成します:

php artisan make:controller AuthController
<?php
namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

class AuthController extends Controller
{
    public function register(Request $request)
    {
        $data = $request->validate([
            'name'     => 'required|string|max:255',
            'email'    => 'required|email|unique:users',
            'password' => 'required|min:8|confirmed',
        ]);

        $user = User::create([
            'name'     => $data['name'],
            'email'    => $data['email'],
            'password' => Hash::make($data['password']),
        ]);

        return response()->json([
            'token' => $user->createToken('api')->plainTextToken,
            'user'  => $user,
        ], 201);
    }

    public function login(Request $request)
    {
        $request->validate([
            'email'    => 'required|email',
            'password' => 'required',
        ]);

        $user = User::where('email', $request->email)->first();

        if (! $user || ! Hash::check($request->password, $user->password)) {
            throw ValidationException::withMessages([
                'email' => ['認証情報が無効です。'],
            ]);
        }

        return response()->json([
            'token' => $user->createToken('api')->plainTextToken,
        ]);
    }

    public function logout(Request $request)
    {
        $request->user()->currentAccessToken()->delete();
        return response()->json(['message' => 'ログアウトしました']);
    }
}

routes/api.php に認証ルートを設定します:

Route::post('/register', [AuthController::class, 'register']);
Route::post('/login',    [AuthController::class, 'login']);

Route::middleware('auth:sanctum')->group(function () {
    Route::post('/logout', [AuthController::class, 'logout']);
    Route::apiResource('posts', PostController::class);
});

応用編 — キュー、APIリソース、レート制限

APIリソースによるレスポンス変換

生のEloquentモデルはクライアントに見せたくないカラムまで公開してしまいます。APIリソースは変換レイヤーとして機能します:

php artisan make:resource PostResource
<?php
namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class PostResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'id'         => $this->id,
            'title'      => $this->title,
            'body'       => $this->body,
            'author'     => $this->whenLoaded('author', fn() => [
                'name'  => $this->author->name,
                'email' => $this->author->email,
            ]),
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}

Redisキューによるバックグラウンドジョブ

メール通知やサードパーティのWebhookを追加したら、すぐにリクエストサイクルの外に出しましょう。RedisはすでにComposeスタックに含まれています:

php artisan make:job SendWelcomeEmail
// registerメソッドで、メール送信の直接呼び出しをこちらに置き換え:
SendWelcomeEmail::dispatch($user)->onQueue('emails');

// コンテナ内でワーカーを起動
php artisan queue:work redis --queue=emails --tries=3

この方式を本番環境で採用していますが、結果は一貫して安定しています——200件以上の登録が同時に押し寄せるバーストトラフィック下でも、ウェルカムメールは一件もドロップしませんでした。

レート制限

Laravelのthrottleミドルウェアは2行でエンドポイントを保護します:

// RouteServiceProvider::boot() 内で設定
RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});

// ルートグループに適用
Route::middleware(['auth:sanctum', 'throttle:api'])->group(...);

6ヶ月間の本番運用で得た実践的なヒント

環境の一致

本番環境での予期せぬトラブルの最大の原因は環境の乖離です。プッシュ前には必ずDockerイメージをビルドしてローカルで動作確認しましょう——php artisan serve だけに頼るのは禁物です。CIパイプラインに以下を追加します:

docker build -t my-api:test .
docker run --rm my-api:test php artisan test

データベースクエリの最適化

N+1クエリを避けるために ->with() を積極的に使いましょう。開発中はLaravel Debugbarをインストールして、ステージング環境に到達する前に問題を検出しましょう:

composer require barryvdh/laravel-debugbar --dev

本番環境向けには、ステージングでクエリログを有効にして、100ms以上のクエリを探しましょう:

DB::listen(function ($query) {
    if ($query->time > 100) {
        Log::warning('スロークエリ', [
            'sql'  => $query->sql,
            'time' => $query->time,
        ]);
    }
});

ヘルスチェックエンドポイント

Dockerやロードバランサーにはヘルスチェックエンドポイントが必要です。実際の依存関係を確認するエンドポイントを追加しましょう:

Route::get('/health', function () {
    DB::connection()->getPdo(); // DBが落ちている場合は例外をスロー
    return response()->json(['status' => 'ok']);
});

イメージに秘密情報を含めない

.env をDockerイメージに焼き込んではいけません。Dockerシークレットか、実行時に注入する環境変数を使いましょう:

# docker-compose.yml — 外部環境ファイルを参照
env_file:
  - .env.production

Laravelの表現力豊かな構文、Sanctumの実用的なトークン認証、そしてDockerによる再現性の高い環境——この組み合わせがAPI開発のほとんどの摩擦を取り除いてくれます。スタックは成熟しており、ドキュメントも充実していて、コミュニティはあなたが遭遇するであろう問題をほぼすべて解決しています。6ヶ月を経て最も驚いたのは、フレームワークと格闘することがいかに少なかったか——Laravelはほとんどの場合、邪魔をせずに黙って仕事をしてくれます。

Share: