Laravel APIの基礎知識
RESTful APIとは何か – Laravelでの位置づけ
RESTful APIは、HTTPプロトコルを利用してリソースの操作を行うアーキテクチャスタイルです。Laravelは、このRESTful APIの開発を強力にサポートしており、効率的なAPI開発を実現するための様々な機能を提供しています。
RESTful APIの主な特徴:
- リソース指向のURL設計
- HTTPメソッドによる操作の明確化(GET, POST, PUT, DELETE)
- ステートレスな通信
- JSONやXMLによるデータのやり取り
LaravelでのRESTful APIエンドポイントの基本例:
// routes/api.php
Route::apiResource('users', UserController::class);
// 上記は以下のルートを自動生成します
GET /api/users - index() // ユーザー一覧の取得
POST /api/users - store() // 新規ユーザーの作成
GET /api/users/{id} - show() // 特定ユーザーの取得
PUT /api/users/{id} - update() // ユーザー情報の更新
DELETE /api/users/{id} - destroy() // ユーザーの削除
なぜLaravelがAPI開発に最適なのか
- 充実した開発ツール群
- Artisanコマンドによる効率的な開発
- APIリソースクラスによる簡潔なJSONレスポンス
- 強力なORM(Eloquent)によるデータベース操作
- セキュリティ機能の標準装備
- CSRF保護
- XSS対策
- SQLインジェクション防止
- Laravel Sanctumによる認証
- 高度な機能の簡単な実装
// APIリソースの例
class UserResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'created_at' => $this->created_at->format('Y-m-d H:i:s')
];
}
}
// コントローラでの使用例
public function show(User $user)
{
return new UserResource($user);
}
API開発に必要な環境設定とパッケージ
1. 基本的な環境設定
.envファイルの重要な設定:
APP_DEBUG=false # 本番環境では必ずfalseに API_PREFIX=api # APIのURLプレフィックス SANCTUM_STATEFUL_DOMAINS=your-domain.com # Sanctum使用時
2. 必須パッケージのインストール
# API認証用のSanctum composer require laravel/sanctum # APIドキュメント生成用のSwagger composer require "darkaonline/l5-swagger" # クロスオリジン通信用のCORS設定 composer require fruitcake/laravel-cors
3. APIの基本設定
app/Providers/RouteServiceProvider.phpでのAPI設定:
public function boot()
{
Route::prefix('api')
->middleware('api')
->namespace($this->namespace)
->group(base_path('routes/api.php'));
}
4. レスポンスヘッダーの設定
app/Http/Middleware/SetApiHeaders.phpの作成:
namespace App\Http\Middleware;
class SetApiHeaders
{
public function handle($request, Closure $next)
{
$response = $next($request);
return $response->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json',
'X-API-Version' => '1.0'
]);
}
}
この基本設定により、セキュアで保守性の高いAPI開発の土台が整います。次のセクションでは、これらの基礎知識を活用した実践的なAPI設計のベストプラクティスについて詳しく解説していきます。
LaravelでのAPI設計のベストプラクティス
APIリソースを活用した効率的なJSONレスポンス設計
APIリソースは、モデルデータをJSON形式に変換する際の一貫性と保守性を高めるための重要な機能です。以下に、効果的な実装パターンを示します。
1. 基本的なリソース設計
// app/Http/Resources/UserResource.php
class UserResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
// 条件付きの属性
'is_admin' => $this->when($this->isAdmin(), true),
// リレーション
'posts' => PostResource::collection($this->whenLoaded('posts')),
// 計算された属性
'full_profile_url' => url("/users/{$this->id}/profile"),
];
}
}
2. コレクションリソースのカスタマイズ
// app/Http/Resources/UserCollection.php
class UserCollection extends ResourceCollection
{
public function toArray($request): array
{
return [
'data' => $this->collection,
'meta' => [
'total_users' => $this->collection->count(),
'version' => '1.0',
],
];
}
}
ルーティングとコントローラーの適切な構造化
1. ルーティングの体系的な整理
// routes/api.php
Route::prefix('v1')->group(function () {
// 認証が必要なルート
Route::middleware('auth:sanctum')->group(function () {
// ユーザー関連
Route::apiResource('users', UserController::class);
Route::apiResource('users.posts', UserPostController::class);
// 管理者専用
Route::prefix('admin')->middleware('admin')->group(function () {
Route::apiResource('stats', StatsController::class);
});
});
// 認証不要なパブリックAPI
Route::get('health', [HealthController::class, 'check']);
Route::get('docs', [DocsController::class, 'index']);
});
2. コントローラーの構造化パターン
// app/Http/Controllers/Api/V1/UserController.php
class UserController extends Controller
{
private UserService $userService;
public function __construct(UserService $userService)
{
$this->userService = $userService;
}
public function index(UserIndexRequest $request)
{
$users = $this->userService->listUsers($request->validated());
return new UserCollection($users);
}
public function store(UserStoreRequest $request)
{
$user = $this->userService->createUser($request->validated());
return new UserResource($user);
}
// 業務ロジックはServiceクラスに委譲
public function update(UserUpdateRequest $request, User $user)
{
$updatedUser = $this->userService->updateUser($user, $request->validated());
return new UserResource($updatedUser);
}
}
バージョニングとエンドポイント設計の考え方
1. APIバージョニングの実装
// app/Providers/RouteServiceProvider.php
public function boot()
{
Route::prefix('api')
->middleware('api')
->group(function () {
// v1のルート
Route::prefix('v1')
->middleware('api.v1')
->group(base_path('routes/api_v1.php'));
// v2のルート(新機能やBreaking Changes)
Route::prefix('v2')
->middleware('api.v2')
->group(base_path('routes/api_v2.php'));
});
}
2. エンドポイント命名規則
RESTfulな命名規則の例:
| アクション | HTTPメソッド | エンドポイント | 説明 |
|---|---|---|---|
| 一覧取得 | GET | /api/v1/users | ユーザー一覧を取得 |
| 詳細取得 | GET | /api/v1/users/{id} | 特定ユーザーの詳細を取得 |
| 作成 | POST | /api/v1/users | 新規ユーザーを作成 |
| 更新 | PUT | /api/v1/users/{id} | ユーザー情報を更新 |
| 部分更新 | PATCH | /api/v1/users/{id} | 特定フィールドのみ更新 |
| 削除 | DELETE | /api/v1/users/{id} | ユーザーを削除 |
| リレーション | GET | /api/v1/users/{id}/posts | ユーザーの投稿一覧を取得 |
3. レスポンスフォーマットの標準化
// app/Traits/ApiResponse.php
trait ApiResponse
{
protected function successResponse($data, $message = null, $code = 200)
{
return response()->json([
'status' => 'success',
'message' => $message,
'data' => $data
], $code);
}
protected function errorResponse($message, $code)
{
return response()->json([
'status' => 'error',
'message' => $message,
'data' => null
], $code);
}
}
これらのベストプラクティスを適用することで、保守性が高く、スケーラブルなAPIを構築することができます。次のセクションでは、これらの設計をベースにした認証システムの実装について詳しく解説していきます。
認証システムの実装
Laravel Sanctumを使用したAPI認証の実装方法
Laravel Sanctumは、SPAやモバイルアプリケーションのための軽量な認証システムを提供します。以下に、段階的な実装手順を示します。
1. Sanctumのインストールと初期設定
# Sanctumのインストール composer require laravel/sanctum # マイグレーションファイルの公開 php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider" # データベースマイグレーションの実行 php artisan migrate
2. モデルの準備
// app/Models/User.php
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
protected $hidden = [
'password',
'remember_token',
];
}
3. 認証コントローラーの実装
// app/Http/Controllers/Api/Auth/LoginController.php
class LoginController extends Controller
{
public function login(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
'device_name' => 'required',
]);
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['認証情報が正しくありません。'],
]);
}
// デバイス名をトークン名として使用
$token = $user->createToken($request->device_name);
return response()->json([
'token' => $token->plainTextToken,
'user' => new UserResource($user),
]);
}
public function logout(Request $request)
{
// 現在のデバイスのトークンを削除
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'ログアウトしました。']);
}
}
トークンベース認証のセキュリティ設定
1. トークンの有効期限設定
// config/sanctum.php
return [
'expiration' => 60 * 24, // 24時間でトークン失効
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', 'dexall_'),
// ステートフルな認証を許可するドメイン
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : ''
))),
];
2. セキュリティミドルウェアの実装
// app/Http/Middleware/EnsureTokenIsValid.php
class EnsureTokenIsValid
{
public function handle($request, Closure $next)
{
if (! $request->user() ||
! $request->user()->tokenCan('api:access')) {
return response()->json([
'message' => '不正なアクセストークンです。'
], 403);
}
return $next($request);
}
}
3. レート制限の設定
// routes/api.php
Route::middleware(['auth:sanctum', 'throttle:api'])
->group(function () {
// レート制限付きのルート
});
// app/Providers/RouteServiceProvider.php
protected function configureRateLimiting()
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
}
ソーシャル認証の統合方法
1. Socialiteのインストールと設定
composer require laravel/socialite
// config/services.php
return [
'google' => [
'client_id' => env('GOOGLE_CLIENT_ID'),
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
'redirect' => env('GOOGLE_REDIRECT_URI'),
],
];
2. ソーシャルログインコントローラーの実装
// app/Http/Controllers/Api/Auth/SocialiteController.php
class SocialiteController extends Controller
{
public function redirectToProvider($provider)
{
return Socialite::driver($provider)->stateless()->redirect();
}
public function handleProviderCallback($provider)
{
try {
$socialUser = Socialite::driver($provider)->stateless()->user();
$user = User::firstOrCreate(
['email' => $socialUser->getEmail()],
[
'name' => $socialUser->getName(),
'password' => Hash::make(Str::random(16)),
'provider' => $provider,
'provider_id' => $socialUser->getId(),
]
);
$token = $user->createToken('socialite');
return response()->json([
'token' => $token->plainTextToken,
'user' => new UserResource($user),
]);
} catch (\Exception $e) {
return response()->json([
'message' => 'ソーシャルログインに失敗しました。'
], 422);
}
}
}
3. セキュリティ強化のためのベストプラクティス
- トークンの保護
- HTTPSの強制使用
- セキュアなクッキー設定
- XSSおよびCSRF対策
// config/sanctum.php
'middleware' => [
'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class,
'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class,
],
- アクセス制御の実装
// routes/api.php
Route::middleware(['auth:sanctum', 'ability:check-status'])->get('/status', function () {
return response()->json(['status' => 'OK']);
});
- トークンの権限管理
// トークン作成時に権限を指定
$token = $user->createToken('api-token', ['view-stats', 'create-posts']);
// 特定の権限を持つトークンのみアクセス可能
if ($request->user()->tokenCan('view-stats')) {
// アクセス許可
}
これらの実装により、セキュアで柔軟な認証システムを構築することができます。次のセクションでは、認証済みユーザーのデータを扱うためのデータベース設計とEloquent API Resourcesについて解説していきます。
データベース設計とEloquent API Resources
効率的なデータベースリレーションの設計
1. マイグレーションとリレーションの基本設計
// database/migrations/2024_02_01_000001_create_posts_table.php
public function up()
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('title');
$table->text('content');
$table->string('status')->default('draft');
$table->timestamp('published_at')->nullable();
$table->timestamps();
// インデックスの追加
$table->index(['status', 'published_at']);
$table->index('user_id');
});
}
// database/migrations/2024_02_01_000002_create_comments_table.php
public function up()
{
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained()->onDelete('cascade');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->text('content');
$table->timestamps();
// 複合インデックス
$table->index(['post_id', 'created_at']);
});
}
2. モデルリレーションの実装
// app/Models/User.php
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
public function posts()
{
return $this->hasMany(Post::class);
}
public function comments()
{
return $this->hasMany(Comment::class);
}
// よく使用される条件をローカルスコープとして定義
public function scopeActive($query)
{
return $query->where('status', 'active');
}
}
// app/Models/Post.php
class Post extends Model
{
protected $casts = [
'published_at' => 'datetime',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function comments()
{
return $this->hasMany(Comment::class);
}
// 投稿のステータスを管理する列挙型
public function status(): Attribute
{
return Attribute::make(
get: fn (string $value) => PostStatus::from($value),
set: fn (PostStatus $status) => $status->value
);
}
}
EloquentリソースによるJSONレスポンスの最適化
1. 基本的なリソース構造
// app/Http/Resources/PostResource.php
class PostResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'content' => $this->content,
'status' => $this->status->value,
'published_at' => $this->published_at?->format('Y-m-d H:i:s'),
'created_at' => $this->created_at->format('Y-m-d H:i:s'),
// 条件付きで含めるリレーション
'user' => new UserResource($this->whenLoaded('user')),
'comments_count' => $this->when(
$request->include_counts,
fn() => $this->comments()->count()
),
// カスタム属性
'excerpt' => Str::limit($this->content, 100),
// 権限に基づく条件付きデータ
'edit_url' => $this->when(
$request->user()?->can('update', $this),
fn() => route('posts.edit', $this->id)
),
];
}
// レスポンスにメタデータを追加
public function with($request): array
{
return [
'meta' => [
'version' => '1.0',
'api_status' => 'stable',
],
];
}
}
2. コレクションリソースの最適化
// app/Http/Resources/PostCollection.php
class PostCollection extends ResourceCollection
{
public function toArray($request): array
{
return [
'data' => $this->collection,
'meta' => [
'total' => $this->collection->count(),
'page' => $this->currentPage(),
'per_page' => $this->perPage(),
'last_page' => $this->lastPage(),
],
];
}
}
N+1問題の解決とパフォーマンス改善
1. Eagerローディングの適切な使用
// app/Http/Controllers/Api/PostController.php
class PostController extends Controller
{
public function index(Request $request)
{
$posts = Post::query()
->when($request->include_user, fn($q) => $q->with('user'))
->when($request->include_comments, fn($q) => $q->with('comments'))
->when(
$request->include_comment_counts,
fn($q) => $q->withCount('comments')
)
->latest()
->paginate();
return new PostCollection($posts);
}
public function show(Post $post, Request $request)
{
// 必要なリレーションのみロード
$post->load($this->getIncludesFromRequest($request));
return new PostResource($post);
}
private function getIncludesFromRequest(Request $request): array
{
return array_filter([
$request->include_user ? 'user' : null,
$request->include_comments ? 'comments.user' : null,
]);
}
}
2. クエリの最適化テクニック
// パフォーマンス最適化のためのクエリビルダー
class PostQueryBuilder extends Builder
{
public function wherePublished(): self
{
return $this->where('status', 'published')
->where('published_at', '<=', now());
}
public function withBasicRelations(): self
{
return $this->with(['user:id,name,email', 'comments:id,post_id,content']);
}
// 必要なカラムのみ選択
public function selectBasicFields(): self
{
return $this->select(['id', 'title', 'user_id', 'created_at']);
}
}
// クエリスコープの活用例
Post::query()
->wherePublished()
->withBasicRelations()
->selectBasicFields()
->latest()
->paginate();
3. キャッシュの効果的な活用
// app/Http/Controllers/Api/PostController.php
public function index(Request $request)
{
$cacheKey = 'posts:' . md5($request->fullUrl());
return Cache::remember($cacheKey, now()->addMinutes(5), function () use ($request) {
$posts = Post::query()
->with($this->getIncludesFromRequest($request))
->latest()
->paginate();
return new PostCollection($posts);
});
}
// キャッシュの自動クリア
class Post extends Model
{
protected static function booted()
{
static::saved(function ($post) {
Cache::tags(['posts'])->flush();
});
}
}
これらの実装により、効率的でスケーラブルなAPIシステムを構築することができます。次のセクションでは、エラーハンドリングとバリデーションについて詳しく解説していきます。
エラーハンドリングとバリデーション
効果的なバリデーションルールの実装
1. フォームリクエストの活用
// app/Http/Requests/Api/PostStoreRequest.php
class PostStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string', 'min:10'],
'category_id' => ['required', 'exists:categories,id'],
'tags' => ['array', 'nullable'],
'tags.*' => ['exists:tags,id'],
'published_at' => ['nullable', 'date', 'after:today'],
'status' => ['required', Rule::in(['draft', 'published', 'archived'])],
];
}
public function messages(): array
{
return [
'title.required' => 'タイトルは必須です。',
'content.min' => '本文は最低10文字必要です。',
'category_id.exists' => '指定されたカテゴリーは存在しません。',
'published_at.after' => '公開日は明日以降の日付を指定してください。',
];
}
// バリデーション前の前処理
protected function prepareForValidation()
{
$this->merge([
'slug' => Str::slug($this->title),
]);
}
}
2. カスタムバリデーションルール
// app/Rules/StrongPassword.php
class StrongPassword implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (strlen($value) < 8) {
$fail('パスワードは8文字以上必要です。');
}
if (!preg_match('/[A-Z]/', $value)) {
$fail('パスワードは大文字を含む必要があります。');
}
if (!preg_match('/[0-9]/', $value)) {
$fail('パスワードは数字を含む必要があります。');
}
}
}
// 使用例
public function rules(): array
{
return [
'password' => ['required', new StrongPassword],
];
}
カスタムエラーレスポンスの設定
1. APIレスポンストレイトの実装
// app/Traits/ApiResponse.php
trait ApiResponse
{
protected function success($data, string $message = null, int $code = 200)
{
return response()->json([
'status' => 'success',
'message' => $message,
'data' => $data
], $code);
}
protected function error(string $message, int $code, $errors = null)
{
$response = [
'status' => 'error',
'message' => $message,
];
if (!is_null($errors)) {
$response['errors'] = $errors;
}
return response()->json($response, $code);
}
}
2. エラーレスポンスの標準化
// app/Exceptions/Handler.php
class Handler extends ExceptionHandler
{
public function register(): void
{
$this->renderable(function (ValidationException $e, $request) {
if ($request->expectsJson()) {
return response()->json([
'status' => 'error',
'message' => '入力内容に誤りがあります。',
'errors' => $e->errors(),
], 422);
}
});
$this->renderable(function (ModelNotFoundException $e, $request) {
if ($request->expectsJson()) {
return response()->json([
'status' => 'error',
'message' => '指定されたリソースが見つかりません。',
], 404);
}
});
}
}
例外処理の体系的なアプローチ
1. カスタム例外クラスの作成
// app/Exceptions/Api/BusinessLogicException.php
class BusinessLogicException extends Exception
{
protected $errors;
public function __construct(string $message, array $errors = [], int $code = 400)
{
parent::__construct($message, $code);
$this->errors = $errors;
}
public function getErrors(): array
{
return $this->errors;
}
}
// app/Exceptions/Api/UnauthorizedAccessException.php
class UnauthorizedAccessException extends Exception
{
public function __construct(string $message = '認証が必要です。')
{
parent::__construct($message, 401);
}
}
2. 例外ハンドラーの拡張
// app/Exceptions/Handler.php
class Handler extends ExceptionHandler
{
protected $dontReport = [
BusinessLogicException::class,
];
public function register(): void
{
// ビジネスロジック例外のハンドリング
$this->renderable(function (BusinessLogicException $e, $request) {
return response()->json([
'status' => 'error',
'message' => $e->getMessage(),
'errors' => $e->getErrors(),
], $e->getCode());
});
// 認証関連の例外ハンドリング
$this->renderable(function (AuthenticationException $e, $request) {
return response()->json([
'status' => 'error',
'message' => '認証に失敗しました。',
], 401);
});
// 認可関連の例外ハンドリング
$this->renderable(function (AuthorizationException $e, $request) {
return response()->json([
'status' => 'error',
'message' => 'この操作を実行する権限がありません。',
], 403);
});
}
}
3. 実践的な例外処理の使用例
// app/Services/PostService.php
class PostService
{
public function createPost(array $data): Post
{
try {
DB::beginTransaction();
// 投稿の作成
$post = Post::create($data);
// タグの関連付け
if (isset($data['tags'])) {
$post->tags()->sync($data['tags']);
}
DB::commit();
return $post;
} catch (\Exception $e) {
DB::rollBack();
throw new BusinessLogicException(
'投稿の作成に失敗しました。',
['system_error' => $e->getMessage()]
);
}
}
public function updatePostStatus(Post $post, string $status): Post
{
if (!in_array($status, ['draft', 'published', 'archived'])) {
throw new BusinessLogicException(
'無効なステータスが指定されました。',
['status' => ['指定可能な値: draft, published, archived']]
);
}
if ($status === 'published' && !$post->isReadyToPublish()) {
throw new BusinessLogicException(
'公開条件を満たしていません。',
['requirements' => $post->getPublishRequirements()]
);
}
$post->update(['status' => $status]);
return $post;
}
}
これらの実装により、エラーを適切に処理し、クライアントに分かりやすいフィードバックを提供することができます。次のセクションでは、APIのテストと品質保証について詳しく解説していきます。
APIのテストと品質保証
PHPUnitを使用した効率的なテスト方法
1. 基本的なAPIテストの構造
// tests/Feature/Api/PostControllerTest.php
class PostControllerTest extends TestCase
{
use RefreshDatabase;
use WithFaker;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->actingAs($this->user);
}
public function test_can_get_posts_list()
{
// テストデータの準備
$posts = Post::factory()->count(3)->create();
// API呼び出し
$response = $this->getJson('/api/v1/posts');
// レスポンスの検証
$response
->assertStatus(200)
->assertJsonStructure([
'data' => [
'*' => [
'id',
'title',
'content',
'created_at'
]
],
'meta' => [
'current_page',
'last_page',
'per_page',
'total'
]
]);
}
public function test_can_create_post()
{
$postData = [
'title' => $this->faker->sentence,
'content' => $this->faker->paragraphs(3, true),
'category_id' => Category::factory()->create()->id,
];
$response = $this->postJson('/api/v1/posts', $postData);
$response
->assertStatus(201)
->assertJson([
'data' => [
'title' => $postData['title'],
'content' => $postData['content'],
]
]);
$this->assertDatabaseHas('posts', [
'title' => $postData['title'],
'user_id' => $this->user->id,
]);
}
}
2. テストケースの体系化
// tests/Feature/Api/PostManagementTest.php
class PostManagementTest extends TestCase
{
use RefreshDatabase;
private Post $post;
private User $admin;
private User $user;
protected function setUp(): void
{
parent::setUp();
// テスト用データの準備
$this->admin = User::factory()->admin()->create();
$this->user = User::factory()->create();
$this->post = Post::factory()->create(['user_id' => $this->user->id]);
}
/**
* @test
* @dataProvider postStatusProvider
*/
public function admin_can_change_post_status(string $status)
{
$this->actingAs($this->admin);
$response = $this->patchJson("/api/v1/posts/{$this->post->id}/status", [
'status' => $status
]);
$response->assertStatus(200);
$this->assertEquals($status, $this->post->fresh()->status);
}
public function postStatusProvider(): array
{
return [
'draft status' => ['draft'],
'published status' => ['published'],
'archived status' => ['archived']
];
}
}
モックとファクトリーを活用したテストデータの作成
1. モデルファクトリーの高度な活用
// database/factories/PostFactory.php
class PostFactory extends Factory
{
protected $model = Post::class;
public function definition(): array
{
return [
'title' => $this->faker->sentence,
'content' => $this->faker->paragraphs(3, true),
'user_id' => User::factory(),
'status' => $this->faker->randomElement(['draft', 'published', 'archived']),
'published_at' => $this->faker->dateTimeBetween('-1 month', '+1 month'),
];
}
// ステータス別のファクトリーステート
public function published(): self
{
return $this->state(function (array $attributes) {
return [
'status' => 'published',
'published_at' => now(),
];
});
}
public function withComments(int $count = 3): self
{
return $this->has(Comment::factory()->count($count));
}
}
2. サービスのモック化
// tests/Unit/Services/PostServiceTest.php
class PostServiceTest extends TestCase
{
private PostService $postService;
private MockInterface $repository;
protected function setUp(): void
{
parent::setUp();
// リポジトリのモック作成
$this->repository = Mockery::mock(PostRepository::class);
$this->postService = new PostService($this->repository);
}
public function test_create_post_with_tags()
{
// モックの期待値を設定
$this->repository
->shouldReceive('create')
->once()
->with(Mockery::type('array'))
->andReturn(new Post(['id' => 1]));
$this->repository
->shouldReceive('attachTags')
->once()
->with(Mockery::type(Post::class), Mockery::type('array'))
->andReturn(true);
// サービスメソッドの実行
$result = $this->postService->createPost([
'title' => 'Test Post',
'content' => 'Test Content',
'tags' => [1, 2, 3]
]);
$this->assertInstanceOf(Post::class, $result);
}
}
CIツールを使用した自動テスト環境の構築
1. GitHub Actionsの設定
# .github/workflows/test.yml
name: Laravel API Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: laravel_test
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: mbstring, xml, ctype, iconv, intl, pdo_mysql
coverage: xdebug
- name: Copy .env
run: php -r "file_exists('.env') || copy('.env.example', '.env');"
- name: Install Dependencies
run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
- name: Generate key
run: php artisan key:generate
- name: Execute tests via PHPUnit
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: laravel_test
DB_USERNAME: root
DB_PASSWORD: password
run: vendor/bin/phpunit --coverage-text
2. テストの自動化スクリプト
#!/bin/bash # scripts/run-tests.sh # 環境変数の設定 export APP_ENV=testing export DB_CONNECTION=sqlite export DB_DATABASE=:memory: # テストの実行 php artisan test --parallel # コードカバレッジレポートの生成 php artisan test --coverage-html reports/coverage
3. PHPUnitの設定最適化
<!-- phpunit.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
<testsuite name="Api">
<directory suffix="Test.php">./tests/Feature/Api</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./app</directory>
</include>
<exclude>
<directory>./app/Console</directory>
<directory>./app/Exceptions</directory>
<directory>./app/Providers</directory>
</exclude>
</coverage>
<php>
<env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>
</phpunit>
これらの実装により、高品質なAPIの開発と保守が可能になります。次のセクションでは、APIのドキュメント化とメンテナンスについて詳しく解説していきます。
APIのドキュメント化とメンテナンス
OpenAPI(Swagger)を使用したドキュメント自動生成
1. L5-Swagger の設定と基本使用法
// app/Http/Controllers/Api/PostController.php
/**
* @OA\Info(
* version="1.0.0",
* title="Laravel API Documentation",
* description="Laravel APIのドキュメント",
* @OA\Contact(
* email="support@example.com"
* )
* )
*/
/**
* @OA\SecurityScheme(
* type="http",
* scheme="bearer",
* bearerFormat="JWT",
* securityScheme="bearerAuth"
* )
*/
class PostController extends Controller
{
/**
* 投稿一覧の取得
*
* @OA\Get(
* path="/api/v1/posts",
* tags={"Posts"},
* summary="投稿一覧を取得",
* @OA\Parameter(
* name="page",
* in="query",
* description="ページ番号",
* required=false,
* @OA\Schema(type="integer")
* ),
* @OA\Parameter(
* name="per_page",
* in="query",
* description="1ページあたりの件数",
* required=false,
* @OA\Schema(type="integer")
* ),
* @OA\Response(
* response=200,
* description="投稿一覧の取得成功",
* @OA\JsonContent(
* @OA\Property(property="data", type="array",
* @OA\Items(ref="#/components/schemas/Post")
* ),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* ),
* security={{"bearerAuth": {}}}
* )
*/
public function index(Request $request)
{
// 実装内容
}
}
2. スキーマ定義の作成
// app/OpenApi/Schemas/Post.php
/**
* @OA\Schema(
* schema="Post",
* required={"id", "title", "content"},
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="title", type="string", example="記事タイトル"),
* @OA\Property(property="content", type="string", example="記事本文"),
* @OA\Property(property="user_id", type="integer", example=1),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(property="updated_at", type="string", format="date-time")
* )
*/
class Post {}
/**
* @OA\Schema(
* schema="PaginationMeta",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="last_page", type="integer", example=5),
* @OA\Property(property="per_page", type="integer", example=15),
* @OA\Property(property="total", type="integer", example=75)
* )
*/
class PaginationMeta {}
APIバージョン管理の実践的アプローチ
1. URIベースのバージョニング実装
// app/Providers/RouteServiceProvider.php
class RouteServiceProvider extends ServiceProvider
{
public function boot()
{
Route::prefix('api')
->middleware('api')
->group(function () {
// v1のルート
Route::prefix('v1')
->middleware('api.v1')
->group(base_path('routes/api_v1.php'));
// v2のルート
Route::prefix('v2')
->middleware('api.v2')
->group(base_path('routes/api_v2.php'));
});
}
}
2. バージョン間の互換性管理
// app/Http/Resources/Api/V2/PostResource.php
class PostResource extends JsonResource
{
public function toArray($request): array
{
// v1との互換性を保ちながら、新しい属性を追加
$baseArray = parent::toArray($request);
return array_merge($baseArray, [
'reading_time' => $this->calculateReadingTime(),
'share_url' => $this->getShareUrl(),
// 非推奨となった属性の警告
'view_count' => $this->when(
$request->header('Accept-Deprecation'),
fn() => $this->views,
fn() => $this->addDeprecationWarning('view_count')
),
]);
}
protected function addDeprecationWarning(string $field): mixed
{
header('X-Deprecated-Field: ' . $field);
return $this->views;
}
}
継続的なモニタリングと性能最適化
1. パフォーマンスモニタリングの実装
// app/Http/Middleware/ApiMetricsMiddleware.php
class ApiMetricsMiddleware
{
public function handle($request, Closure $next)
{
$startTime = microtime(true);
$response = $next($request);
$duration = microtime(true) - $startTime;
// メトリクスの記録
$metrics = [
'endpoint' => $request->path(),
'method' => $request->method(),
'duration' => $duration,
'status' => $response->status(),
'user_id' => $request->user()?->id,
'timestamp' => now(),
];
// Redisに保存
Redis::zadd('api_metrics', now()->timestamp, json_encode($metrics));
return $response;
}
}
2. キャッシュ戦略の実装
// app/Services/CacheService.php
class CacheService
{
public function rememberApi(string $key, $ttl, Closure $callback)
{
$cacheKey = $this->generateCacheKey($key);
return Cache::tags(['api'])->remember($cacheKey, $ttl, $callback);
}
public function invalidateEndpoint(string $endpoint)
{
$pattern = "api:$endpoint:*";
$keys = Redis::keys($pattern);
foreach ($keys as $key) {
Redis::del($key);
}
}
protected function generateCacheKey(string $key): string
{
return "api:{$key}:" . md5(request()->fullUrl());
}
}
// 使用例
class PostController extends Controller
{
public function index(Request $request, CacheService $cache)
{
return $cache->rememberApi('posts.index', now()->addMinutes(5), function () {
return Post::paginate();
});
}
}
3. 性能監視とアラート設定
// app/Console/Commands/MonitorApiPerformance.php
class MonitorApiPerformance extends Command
{
protected $signature = 'api:monitor';
public function handle()
{
// 直近5分間のメトリクスを分析
$metrics = Redis::zrangebyscore(
'api_metrics',
now()->subMinutes(5)->timestamp,
now()->timestamp
);
$slowEndpoints = collect($metrics)
->map(fn($m) => json_decode($m, true))
->groupBy('endpoint')
->map(function ($group) {
return [
'avg_duration' => $group->avg('duration'),
'count' => $group->count(),
'error_rate' => $group->where('status', '>=', 400)->count() / $group->count(),
];
})
->filter(function ($stats) {
return $stats['avg_duration'] > 1.0 || // 1秒以上
$stats['error_rate'] > 0.05; // エラー率5%以上
});
if ($slowEndpoints->isNotEmpty()) {
// Slackなどに通知
Notification::route('slack', env('SLACK_WEBHOOK_URL'))
->notify(new SlowApiEndpointsNotification($slowEndpoints));
}
}
}
これらの実装により、APIの長期的な保守性と安定性を確保することができます。次のセクションでは、実践的なユースケースと実装例について詳しく解説していきます。
実践的なユースケースと実装例
ファイルアップロード機能の実装方法
1. セキュアなファイルアップロード処理
// app/Http/Controllers/Api/FileUploadController.php
class FileUploadController extends Controller
{
/**
* @OA\Post(
* path="/api/v1/upload",
* summary="ファイルをアップロード",
* @OA\RequestBody(
* @OA\MediaType(
* mediaType="multipart/form-data",
* @OA\Schema(
* @OA\Property(
* property="file",
* type="string",
* format="binary"
* )
* )
* )
* )
* )
*/
public function store(FileUploadRequest $request)
{
try {
$file = $request->file('file');
$path = $file->hashName('uploads');
// S3へのアップロード
$url = Storage::disk('s3')->put($path, $file->get(), [
'ContentType' => $file->getMimeType(),
'ACL' => 'private',
'CacheControl' => 'max-age=31536000'
]);
// データベースに記録
$upload = FileUpload::create([
'user_id' => auth()->id(),
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'path' => $path,
]);
// 署名付きURL生成
$signedUrl = Storage::disk('s3')->temporaryUrl(
$path,
now()->addMinutes(5)
);
return response()->json([
'message' => 'ファイルのアップロードに成功しました。',
'data' => [
'id' => $upload->id,
'url' => $signedUrl,
]
]);
} catch (\Exception $e) {
Log::error('ファイルアップロードエラー: ' . $e->getMessage());
return response()->json([
'message' => 'ファイルのアップロードに失敗しました。'
], 500);
}
}
}
// app/Http/Requests/FileUploadRequest.php
class FileUploadRequest extends FormRequest
{
public function rules(): array
{
return [
'file' => [
'required',
'file',
'max:10240', // 10MB
'mimes:jpeg,png,pdf,doc,docx',
]
];
}
protected function failedValidation(Validator $validator)
{
throw new HttpResponseException(
response()->json([
'message' => 'バリデーションエラー',
'errors' => $validator->errors()
], 422)
);
}
}
ページネーションと検索機能の実装
1. 高度な検索機能の実装
// app/Http/Controllers/Api/SearchController.php
class SearchController extends Controller
{
private PostRepository $repository;
public function __construct(PostRepository $repository)
{
$this->repository = $repository;
}
public function search(SearchRequest $request)
{
$results = $this->repository->search($request->validated());
return PostResource::collection($results)
->additional([
'meta' => [
'total' => $results->total(),
'filters' => $request->validated(),
]
]);
}
}
// app/Repositories/PostRepository.php
class PostRepository
{
public function search(array $params)
{
return Post::query()
->when($params['query'] ?? null, function ($query, $searchTerm) {
$query->where(function ($q) use ($searchTerm) {
$q->where('title', 'like', "%{$searchTerm}%")
->orWhere('content', 'like', "%{$searchTerm}%");
});
})
->when($params['category'] ?? null, function ($query, $category) {
$query->whereHas('category', function ($q) use ($category) {
$q->where('slug', $category);
});
})
->when($params['tags'] ?? null, function ($query, $tags) {
$query->whereHas('tags', function ($q) use ($tags) {
$q->whereIn('slug', explode(',', $tags));
});
})
->when($params['date_from'] ?? null, function ($query, $date) {
$query->where('created_at', '>=', Carbon::parse($date));
})
->when($params['date_to'] ?? null, function ($query, $date) {
$query->where('created_at', '<=', Carbon::parse($date));
})
->orderBy($params['sort_by'] ?? 'created_at', $params['sort_direction'] ?? 'desc')
->paginate($params['per_page'] ?? 15);
}
}
2. カーソルベースのページネーション実装
// app/Http/Controllers/Api/PostController.php
class PostController extends Controller
{
public function index(Request $request)
{
$posts = Post::query()
->when($request->after, function ($query, $afterId) {
$query->where('id', '>', $afterId);
})
->take($request->limit ?? 20)
->orderBy('id')
->get();
return response()->json([
'data' => PostResource::collection($posts),
'meta' => [
'has_more' => $posts->count() === ($request->limit ?? 20),
'next_cursor' => $posts->last()?->id,
]
]);
}
}
WebSocketを使用したリアルタイム通信の統合
1. Laravel WebSocketsの設定
// config/websockets.php
return [
'dashboard' => [
'port' => env('LARAVEL_WEBSOCKETS_PORT', 6001),
],
'apps' => [
[
'id' => env('PUSHER_APP_ID'),
'name' => env('APP_NAME'),
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'enable_client_messages' => false,
'enable_statistics' => true,
],
],
];
2. リアルタイムイベントの実装
// app/Events/PostCreated.php
class PostCreated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public Post $post
) {}
public function broadcastOn(): array
{
return [
new PrivateChannel("user.{$this->post->user_id}"),
new Channel('posts'),
];
}
public function broadcastWith(): array
{
return [
'id' => $this->post->id,
'title' => $this->post->title,
'excerpt' => Str::limit($this->post->content, 100),
'author' => [
'id' => $this->post->user->id,
'name' => $this->post->user->name,
],
];
}
}
// app/Http/Controllers/Api/PostController.php
public function store(PostStoreRequest $request)
{
$post = Post::create($request->validated());
// リアルタイムイベントの発火
broadcast(new PostCreated($post))->toOthers();
return new PostResource($post);
}
3. WebSocketクライアント認証
// routes/channels.php
Broadcast::channel('user.{id}', function ($user, $id) {
return (int) $user->id === (int) $id;
});
Broadcast::channel('posts', function ($user) {
return ['id' => $user->id, 'name' => $user->name];
});
// app/Providers/BroadcastServiceProvider.php
class BroadcastServiceProvider extends ServiceProvider
{
public function boot()
{
Broadcast::routes(['middleware' => ['auth:sanctum']]);
}
}
4. WebSocket接続のエラーハンドリング
// app/Exceptions/WebSocketHandler.php
class WebSocketHandler
{
public function handle($connection, $exception)
{
Log::error('WebSocket Error: ' . $exception->getMessage(), [
'connection_id' => $connection->socketId,
'user_id' => $connection->user?->id,
]);
$connection->send(json_encode([
'event' => 'error',
'data' => [
'message' => 'WebSocket接続でエラーが発生しました。',
'code' => $exception->getCode(),
]
]));
}
}
これらの実装例により、実践的なAPI機能を効率的に開発することができます。セキュリティ、パフォーマンス、スケーラビリティを考慮しながら、必要に応じて機能を拡張していくことが重要です。