概要
この記事ではLaravel11を利用して基本的なAPIを実装手順を紹介します。
実装する機能
- 商品一覧の取得(検索、ソート、ページネーション)
- 商品の登録
- 商品の詳細取得
- 商品の更新
- 商品の削除(論理削除、物理削除、復元)
実装手順
1. Laravel11のプロジェクトの作成
composer create-project laravel/laravel=11.x product-management-api
cd product-management-api
2. モデルとマイグレーションの作成
まず、Product モデルとマイグレーションファイルを作成します。
php artisan make:model Product -m
マイグレーションファイル(database/migrations/xxxx_xx_xx_create_products_table.php
):
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->integer('price');
$table->integer('stock');
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->softDeletes();
});
}
Product モデル(app/Models/Product.php
):
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Product extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'name',
'description',
'price',
'stock',
'is_active'
];
protected $casts = [
'price' => 'integer',
'stock' => 'integer',
'is_active' => 'boolean',
];
}
3. API リソースの作成
php artisan make:resource ProductResource
app/Http/Resources/ProductResource.php
:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ProductResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'price' => $this->price,
'stock' => $this->stock,
'is_active' => $this->is_active,
'created_at' => $this->created_at->format('Y-m-d H:i:s'),
'updated_at' => $this->updated_at->format('Y-m-d H:i:s'),
];
}
}
4. バリデーションの作成
商品登録用のリクエストクラス:
php artisan make:request Api/CreateProductRequest
<?php
namespace App\Http\Requests\Api;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;
use Symfony\Component\HttpFoundation\Response;
class CreateProductRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:1000'],
'price' => ['required', 'integer', 'min:0'],
'stock' => ['required', 'integer', 'min:0'],
'is_active' => ['boolean'],
];
}
public function messages(): array
{
return [
'name.required' => '商品名は必須です',
'name.max' => '商品名は255文字以内で入力してください',
'description.max' => '商品説明は1000文字以内で入力してください',
'price.required' => '価格は必須です',
'price.integer' => '価格は整数で入力してください',
'price.min' => '価格は0以上で入力してください',
'stock.required' => '在庫数は必須です',
'stock.integer' => '在庫数は整数で入力してください',
'stock.min' => '在庫数は0以上で入力してください',
'is_active.boolean' => '商品状態は真偽値で入力してください',
];
}
protected function failedValidation(Validator $validator)
{
throw new HttpResponseException(
response()->json([
'message' => 'Validation failed',
'errors' => $validator->errors(),
], Response::HTTP_UNPROCESSABLE_ENTITY)
);
}
}
商品更新用のリクエストクラス:
php artisan make:request Api/UpdateProductRequest
<?php
namespace App\Http\Requests\Api;
class UpdateProductRequest extends CreateProductRequest
{
public function rules(): array
{
$rules = parent::rules();
// 更新時は送信されたフィールドのみをバリデーション
return array_map(function ($rule) {
return array_merge(['sometimes'], $rule);
}, $rules);
}
}
5. コントローラーの作成
php artisan make:controller Api/ProductController
app/Http/Controllers/Api/ProductController.php
:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\CreateProductRequest;
use App\Http\Requests\Api\UpdateProductRequest;
use App\Http\Resources\ProductResource;
use App\Models\Product;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Symfony\Component\HttpFoundation\Response;
class ProductController extends Controller
{
/**
* 許可されているソートカラムのリスト
*/
private const ALLOWED_SORT_COLUMNS = [
'id',
'name',
'price',
'stock',
'created_at',
'updated_at'
];
/**
* 商品一覧を取得
*/
public function index(Request $request): AnonymousResourceCollection
{
try {
$query = Product::query();
// 検索条件の追加
if ($request->has('name')) {
$query->where('name', 'like', '%' . $request->input('name') . '%');
}
if ($request->has('is_active')) {
$query->where('is_active', $request->boolean('is_active'));
}
// ソート条件の追加
$sortBy = $request->input('sort_by', 'created_at');
$sortOrder = $request->input('sort_order', 'desc');
// 不正なソートカラムのチェック
if (!in_array($sortBy, self::ALLOWED_SORT_COLUMNS, true)) {
throw new \InvalidArgumentException("Invalid sort column: {$sortBy}");
}
$query->orderBy($sortBy, $sortOrder);
// ページネーション
$products = $query->paginate($request->input('per_page', 15));
return ProductResource::collection($products);
} catch (\InvalidArgumentException $e) {
abort(Response::HTTP_BAD_REQUEST, $e->getMessage());
} catch (\Exception $e) {
abort(Response::HTTP_INTERNAL_SERVER_ERROR, 'Failed to fetch products.');
}
}
/**
* 商品を作成
*/
public function store(CreateProductRequest $request): JsonResponse
{
try {
$product = Product::create($request->validated());
return response()->json([
'message' => 'Product created successfully',
'data' => new ProductResource($product)
], Response::HTTP_CREATED);
} catch (\Exception $e) {
return response()->json([
'message' => 'Failed to create product',
'error' => $e->getMessage()
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* 商品を更新
*/
public function update(UpdateProductRequest $request, int $id): JsonResponse
{
try {
$product = Product::findOrFail($id);
$product->update($request->validated());
return response()->json([
'message' => 'Product updated successfully',
'data' => new ProductResource($product)
], Response::HTTP_OK);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return response()->json([
'message' => 'Product not found'
], Response::HTTP_NOT_FOUND);
} catch (\Exception $e) {
return response()->json([
'message' => 'Failed to update product',
'error' => $e->getMessage()
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* 商品を取得
*/
public function show(int $id): JsonResponse
{
try {
$product = Product::findOrFail($id);
return response()->json([
'data' => new ProductResource($product)
], Response::HTTP_OK);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return response()->json([
'message' => 'Product not found'
], Response::HTTP_NOT_FOUND);
} catch (\Exception $e) {
return response()->json([
'message' => 'Failed to fetch product',
'error' => $e->getMessage()
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* 商品を削除
*/
public function destroy(int $id): JsonResponse
{
try {
// withTrashedを追加して、削除済みのレコードも検索対象に含める
$product = Product::withTrashed()->findOrFail($id);
if ($product->trashed()) {
return response()->json([
'message' => 'Product is already deleted'
], Response::HTTP_BAD_REQUEST);
}
$product->delete();
return response()->json([
'message' => 'Product deleted successfully'
], Response::HTTP_OK);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return response()->json([
'message' => 'Product not found'
], Response::HTTP_NOT_FOUND);
} catch (\Exception $e) {
return response()->json([
'message' => 'Failed to delete product',
'error' => $e->getMessage()
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* 商品を強制的に削除
*/
public function forceDestroy(int $id): JsonResponse
{
try {
$product = Product::withTrashed()->findOrFail($id);
$product->forceDelete();
return response()->json([
'message' => 'Product permanently deleted successfully'
], Response::HTTP_OK);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return response()->json([
'message' => 'Product not found'
], Response::HTTP_NOT_FOUND);
} catch (\Exception $e) {
return response()->json([
'message' => 'Failed to permanently delete product',
'error' => $e->getMessage()
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* 削除済み商品の復元
*/
public function restore(int $id): JsonResponse
{
try {
$product = Product::withTrashed()->findOrFail($id);
if (!$product->trashed()) {
return response()->json([
'message' => 'Product is not deleted'
], Response::HTTP_BAD_REQUEST);
}
$product->restore();
return response()->json([
'message' => 'Product restored successfully',
'data' => new ProductResource($product)
], Response::HTTP_OK);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return response()->json([
'message' => 'Product not found'
], Response::HTTP_NOT_FOUND);
} catch (\Exception $e) {
return response()->json([
'message' => 'Failed to restore product',
'error' => $e->getMessage()
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
5. ルーティングの設定
Laravel11のデフォルトではroutes/api.php
が存在しないため以下のコマンドで生成してください。
php artisan install:api
routes/api.php
:
<?php
use App\Http\Controllers\Api\ProductController;
Route::get('/products', [ProductController::class, 'index']);
Route::post('/products', [ProductController::class, 'store']);
Route::get('/products/{id}', [ProductController::class, 'show']);
Route::put('/products/{id}', [ProductController::class, 'update']);
Route::delete('/products/{id}', [ProductController::class, 'destroy']);
Route::delete('/products/{id}/force', [ProductController::class, 'forceDestroy']);
Route::patch('/products/{id}/restore', [ProductController::class, 'restore']);
6. テストの作成
php artisan make:test Api/ProductTest
tests/Feature/Api/ProductTest.php
:
<?php
namespace Tests\Feature\Api;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Symfony\Component\HttpFoundation\Response;
use Tests\TestCase;
/**
* 商品APIのテスト
*
*/
class ProductTest extends TestCase
{
use RefreshDatabase;
/**
* テストデータの作成
*/
public function setUp(): void
{
parent::setUp();
// テストデータの作成
Product::factory(30)->create();
}
/**
* 商品一覧を取得できるかのテスト
*/
public function test_can_fetch_products_list(): void
{
$response = $this->getJson('/api/products');
$response->assertStatus(200)
->assertJsonStructure([
'data' => [
'*' => [
'id',
'name',
'description',
'price',
'stock',
'is_active',
'created_at',
'updated_at',
],
],
'links',
'meta',
]);
}
/**
* 商品名で検索できるかのテスト
*/
public function test_can_fetch_products_with_search_params(): void
{
// 特定の名前を持つ商品を作成
$searchName = 'テスト商品';
Product::factory()->create(['name' => $searchName]);
$response = $this->getJson("/api/products?name={$searchName}");
$response->assertStatus(200)
->assertJsonFragment(['name' => $searchName]);
}
/**
* アクティブな商品のみ取得できるかのテスト
*/
public function test_can_fetch_active_products_only(): void
{
// アクティブな商品を作成
Product::factory(5)->create(['is_active' => true]);
// 非アクティブな商品を作成
Product::factory(5)->create(['is_active' => false]);
$response = $this->getJson('/api/products?is_active=true');
$response->assertStatus(200);
$products = $response->json('data');
collect($products)->each(function ($product) {
$this->assertTrue($product['is_active']);
});
}
/**
* 商品を価格でソートできるかのテスト
*/
public function test_can_sort_products(): void
{
// 価格の異なる商品を作成
Product::factory()->create(['price' => 1000]);
Product::factory()->create(['price' => 2000]);
Product::factory()->create(['price' => 3000]);
$response = $this->getJson('/api/products?sort_by=price&sort_order=desc');
$response->assertStatus(200);
$products = $response->json('data');
$this->assertTrue($products[0]['price'] > $products[1]['price']);
}
/**
* ページネーションができるかのテスト
*/
public function test_can_paginate_products(): void
{
$perPage = 5;
$response = $this->getJson("/api/products?per_page={$perPage}");
$response->assertStatus(200)
->assertJsonCount($perPage, 'data')
->assertJsonStructure([
'meta' => [
'current_page',
'from',
'last_page',
'per_page',
'to',
'total',
],
]);
}
/**
* 無効なソートパラメータのテスト
*/
public function test_handles_invalid_sort_parameter(): void
{
$response = $this->getJson('/api/products?sort_by=invalid_column');
$response->assertStatus(Response::HTTP_BAD_REQUEST)
->assertJsonFragment([
'message' => 'Invalid sort column: invalid_column'
]);
}
/**
* 商品を作成できるかのテスト
*/
public function test_can_create_product(): void
{
$productData = [
'name' => 'テスト商品',
'description' => '商品の説明文です',
'price' => 1000,
'stock' => 10,
'is_active' => true,
];
$response = $this->postJson('/api/products', $productData);
$response->assertStatus(Response::HTTP_CREATED)
->assertJsonStructure([
'message',
'data' => [
'id',
'name',
'description',
'price',
'stock',
'is_active',
'created_at',
'updated_at',
]
]);
$this->assertDatabaseHas('products', $productData);
}
/**
* 無効なデータで商品を作成できないかのテスト
*/
public function test_cannot_create_product_with_invalid_data(): void
{
$invalidData = [
'name' => '', // 必須項目を空にする
'price' => -1, // 負の値は不許可
'stock' => 'invalid', // 数値以外は不許可
];
$response = $this->postJson('/api/products', $invalidData);
$response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
->assertJsonStructure([
'message',
'errors' => [
'name',
'price',
'stock',
]
]);
}
/**
* 商品を更新できるかのテスト
*/
public function test_can_update_product(): void
{
$product = Product::factory()->create();
$updateData = [
'name' => '更新後の商品名',
'price' => 2000,
];
$response = $this->putJson("/api/products/{$product->id}", $updateData);
$response->assertStatus(Response::HTTP_OK)
->assertJsonStructure([
'message',
'data' => [
'id',
'name',
'description',
'price',
'stock',
'is_active',
'created_at',
'updated_at',
]
])
->assertJsonFragment([
'name' => '更新後の商品名',
'price' => 2000,
]);
$this->assertDatabaseHas('products', $updateData);
}
/**
* 無効なデータで商品を更新できないかのテスト
*/
public function test_cannot_update_product_with_invalid_data(): void
{
$product = Product::factory()->create();
$invalidData = [
'name' => '', // 空の名前は不許可
'price' => -1, // 負の価格は不許可
];
$response = $this->putJson("/api/products/{$product->id}", $invalidData);
$response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
->assertJsonStructure([
'message',
'errors' => [
'name',
'price',
]
]);
}
/**
* 存在しない商品を更新しようとした場合のテスト
*/
public function test_cannot_update_non_existent_product(): void
{
$nonExistentId = 9999;
$updateData = [
'name' => '更新後の商品名',
'price' => 2000,
];
$response = $this->putJson("/api/products/{$nonExistentId}", $updateData);
$response->assertStatus(Response::HTTP_NOT_FOUND)
->assertJsonFragment([
'message' => 'Product not found'
]);
}
/**
* 商品を取得できるかのテスト
*/
public function test_can_fetch_single_product(): void
{
$product = Product::factory()->create([
'name' => 'テスト商品',
'description' => '商品の説明文',
'price' => 1000,
'stock' => 10,
'is_active' => true,
]);
$response = $this->getJson("/api/products/{$product->id}");
$response->assertStatus(Response::HTTP_OK)
->assertJsonStructure([
'data' => [
'id',
'name',
'description',
'price',
'stock',
'is_active',
'created_at',
'updated_at',
]
])
->assertJsonFragment([
'name' => 'テスト商品',
'description' => '商品の説明文',
'price' => 1000,
'stock' => 10,
'is_active' => true,
]);
}
/**
* 存在しない商品を取得しようとした場合のテスト
*/
public function test_cannot_fetch_non_existent_product(): void
{
$nonExistentId = 9999;
$response = $this->getJson("/api/products/{$nonExistentId}");
$response->assertStatus(Response::HTTP_NOT_FOUND)
->assertJsonFragment([
'message' => 'Product not found'
]);
}
/**
* 削除された商品を取得しようとした場合のテスト
*/
public function test_cannot_fetch_deleted_product(): void
{
$product = Product::factory()->create();
$product->delete();
$response = $this->getJson("/api/products/{$product->id}");
$response->assertStatus(Response::HTTP_NOT_FOUND)
->assertJsonFragment([
'message' => 'Product not found'
]);
}
/**
* 無効なID形式で商品を取得しようとした場合のテスト
*/
public function test_returns_error_for_invalid_id_format(): void
{
$response = $this->getJson('/products/invalid-id');
$response->assertStatus(Response::HTTP_NOT_FOUND);
}
/**
* 商品を論理削除できるかのテスト
*/
public function test_can_soft_delete_product(): void
{
$product = Product::factory()->create();
$response = $this->deleteJson("/api/products/{$product->id}");
$response->assertStatus(Response::HTTP_OK)
->assertJsonFragment([
'message' => 'Product deleted successfully'
]);
$this->assertSoftDeleted('products', [
'id' => $product->id
]);
}
/**
* 存在しない商品を削除しようとした場合のテスト
*/
public function test_cannot_delete_non_existent_product(): void
{
$nonExistentId = 9999;
$response = $this->deleteJson("/api/products/{$nonExistentId}");
$response->assertStatus(Response::HTTP_NOT_FOUND)
->assertJsonFragment([
'message' => 'Product not found'
]);
}
/**
* 削除済みの商品を削除しようとした場合のテスト
*/
public function test_cannot_delete_already_deleted_product(): void
{
$product = Product::factory()->create();
$product->delete();
$response = $this->deleteJson("/api/products/{$product->id}");
$response->assertStatus(Response::HTTP_BAD_REQUEST)
->assertJsonFragment([
'message' => 'Product is already deleted'
]);
}
/**
* 商品を強制的に削除できるかのテスト
*/
public function test_can_force_delete_product(): void
{
$product = Product::factory()->create();
$product->delete(); // まずソフトデリート
$response = $this->deleteJson("/api/products/{$product->id}/force");
$response->assertStatus(Response::HTTP_OK)
->assertJsonFragment([
'message' => 'Product permanently deleted successfully'
]);
$this->assertDatabaseMissing('products', [
'id' => $product->id
]);
}
/**
* 削除済みの商品を復元できるかのテスト
*/
public function test_can_restore_deleted_product(): void
{
$product = Product::factory()->create();
$product->delete();
$response = $this->patchJson("/api/products/{$product->id}/restore"); // postJsonからpatchJsonに変更
$response->assertStatus(Response::HTTP_OK)
->assertJsonFragment([
'message' => 'Product restored successfully'
]);
$this->assertDatabaseHas('products', [
'id' => $product->id,
'deleted_at' => null
]);
}
/**
* 削除済みでない商品を復元しようとした場合のテスト
*/
public function test_cannot_restore_non_deleted_product(): void
{
$product = Product::factory()->create();
$response = $this->patchJson("/api/products/{$product->id}/restore"); // postJsonからpatchJsonに変更
$response->assertStatus(Response::HTTP_BAD_REQUEST)
->assertJsonFragment([
'message' => 'Product is not deleted'
]);
}
}
.env
の内容をコピーして、.env.testing
を作成する
cp .env .env.testing
.env.testing
に以下を追加:
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
テスト実行:
php artisan test tests/Feature/Api/ProductTest.php
PASS Tests\Feature\Api\ProductTest
✓ can fetch products list 0.14s
✓ can fetch products with search params 0.04s
✓ can fetch active products only 0.04s
✓ can sort products 0.03s
✓ can paginate products 0.04s
✓ handles invalid sort parameter 0.03s
✓ can create product 0.04s
✓ cannot create product with invalid data 0.04s
✓ can update product 0.03s
✓ cannot update product with invalid data 0.03s
✓ cannot update non existent product 0.04s
✓ can fetch single product 0.03s
✓ cannot fetch non existent product 0.03s
✓ cannot fetch deleted product 0.04s
✓ returns error for invalid id format 0.03s
✓ can soft delete product 0.03s
✓ cannot delete non existent product 0.04s
✓ cannot delete already deleted product 0.03s
✓ can force delete product 0.03s
✓ can restore deleted product 0.04s
✓ cannot restore non deleted product 0.03s
Tests: 21 passed (230 assertions)
Duration: 0.90s
7. ダミーデータの作成(オプション)
まず、ProductFactoryを作成します:
php artisan make:factory ProductFactory
database/factories/ProductFactory.php
:
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
class ProductFactory extends Factory
{
public function definition(): array
{
return [
'name' => fake()->realText(20),
'description' => fake()->realText(200),
'price' => fake()->numberBetween(100, 100000),
'stock' => fake()->numberBetween(0, 100),
'is_active' => fake()->boolean(80), // 80%の確率でtrue
'created_at' => fake()->dateTimeBetween('-1 year', 'now'),
'updated_at' => function (array $attributes) {
return fake()->dateTimeBetween($attributes['created_at'], 'now');
},
];
}
}
次に、シーダーを作成します:
php artisan make:seeder ProductSeeder
database/seeders/ProductSeeder.php
:
<?php
namespace Database\Seeders;
use App\Models\Product;
use Illuminate\Database\Seeder;
class ProductSeeder extends Seeder
{
public function run(): void
{
Product::factory(50)->create();
}
}
database/seeders/DatabaseSeeder.php
:
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
ProductSeeder::class,
]);
}
}
テストデータを投入します:
php artisan db:seed
API エンドポイント一覧
メソッド | エンドポイント | 説明 |
---|---|---|
GET | /api/products | 商品一覧の取得 |
POST | /api/products | 商品の登録 |
GET | /api/products/{id} | 商品詳細の取得 |
PUT | /api/products/{id} | 商品の更新 |
DELETE | /api/products/{id} | 商品の論理削除 |
DELETE | /api/products/{id}/force | 商品の物理削除 |
PATCH | /api/products/{id}/restore | 削除商品の復元 |
さいごに
この記事ではLaravel 11を使用して基本的な商品管理APIを実装しました。実装のポイントを振り返ってみます。
- 基本に忠実な実装
- Laravel の機能を活用したCRUD操作
- RESTful APIの原則に従った設計
- リクエストクラスによるバリデーション
- 実践的な機能
- 検索、ソート、ページネーション
- 論理削除と復元
- エラーハンドリング
- テストの重要性
- 各機能の動作確認
- エッジケースのテスト
- テストコードによる仕様明確化
学んだ内容を土台として、認証機能の実装やキャッシュによるパフォーマンス改善、API仕様書の作成など、さらなる学習を進めていければと思います。