laravel-patterns について
LaravelアプリやAPIの構成とドメインロジックを整理する開発パターンのスキルです。ルーティング、Eloquent、バリデーション、キュー、キャッシュ、設定の慣例を具体例とともに扱います。
主な機能
- 01コントローラー、サービス、アクションの分離
- 02スコープ付きルートモデルバインディング
- 03型付きEloquentモデルとスコープの活用
- 04APIリソースとページネーションの構成
- 05キュー、イベント、キャッシュの処理
- 06環境別の設定管理
活用シーン
- 01Laravel APIやWebアプリの構成
- 02ネストしたルートでのテナント間アクセス防止
- 03N+1クエリの回避とページネーション
- 04時間のかかる処理のキュー化
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ midudev/ autoskills --skill laravel-patternsインストール後、Claude Code で/laravel-patternsを入力して読み込まれたか確認します。
詳細
Laravel 開発パターン
スケーラブルで保守しやすいアプリケーションのための、本番環境対応 Laravel アーキテクチャパターンです。
使用する場面
- Laravel の Web アプリケーションまたは API を構築するとき
- コントローラー、サービス、ドメインロジックを構成するとき
- Eloquent モデルとリレーションシップを扱うとき
- リソースとページネーションを使って API を設計するとき
- キュー、イベント、キャッシュ、バックグラウンドジョブを追加するとき
仕組み
- 明確な境界(コントローラー -> サービス/アクション -> モデル)を中心にアプリを構成します。
- ルーティングを予測しやすくするため、明示的なバインディングとスコープ付きバインディングを使用します。アクセス制御には、引き続き認可を適用してください。
- 型を明示したモデル、キャスト、スコープを活用して、ドメインロジックの一貫性を保ちます。
- I/O 負荷の高い処理はキューに入れ、コストの高い読み取りをキャッシュします。
- 設定を
config/*に集約し、環境を明確に分けます。
例
プロジェクト構成
HTTP、サービス/アクション、モデルのレイヤー境界が明確な、標準的な Laravel のディレクトリ構成を使用します。
推奨レイアウト
app/
├── Actions/ # Single-purpose use cases
├── Console/
├── Events/
├── Exceptions/
├── Http/
│ ├── Controllers/
│ ├── Middleware/
│ ├── Requests/ # Form request validation
│ └── Resources/ # API resources
├── Jobs/
├── Models/
├── Policies/
├── Providers/
├── Services/ # Coordinating domain services
└── Support/
config/
database/
├── factories/
├── migrations/
└── seeders/
resources/
├── views/
└── lang/
routes/
├── api.php
├── web.php
└── console.php
コントローラー -> サービス -> アクション
コントローラーは薄く保ちます。処理の調整はサービスに、単一目的のロジックはアクションに配置します。
final class CreateOrderAction
{
public function __construct(private OrderRepository $orders) {}
public function handle(CreateOrderData $data): Order
{
return $this->orders->create($data);
}
}
final class OrdersController extends Controller
{
public function __construct(private CreateOrderAction $createOrder) {}
public function store(StoreOrderRequest $request): JsonResponse
{
$order = $this->createOrder->handle($request->toDto());
return response()->json([
'success' => true,
'data' => OrderResource::make($order),
'error' => null,
'meta' => null,
], 201);
}
}
ルーティングとコントローラー
明確さを保つため、ルートモデルバインディングとリソースコントローラーを優先します。
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('projects', ProjectController::class);
});
ルートモデルバインディング(スコープ付き)
テナントをまたいだアクセスを防ぐため、スコープ付きバインディングを使用します。
Route::scopeBindings()->group(function () {
Route::get('/accounts/{account}/projects/{project}', [ProjectController::class, 'show']);
});
ネストされたルートとバインディング名
- 二重のネストを避けるため、プレフィックスとパスに一貫性を持たせます(例:
conversationとconversations)。 - バインドするモデルに一致する単一のパラメーター名を使用します(例:
Conversationには{conversation})。 - ネストする場合は、親子関係を適用するため、スコープ付きバインディングを優先します。
use App\Http\Controllers\Api\ConversationController;
use App\Http\Controllers\Api\MessageController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->prefix('conversations')->group(function () {
Route::post('/', [ConversationController::class, 'store'])->name('conversations.store');
Route::scopeBindings()->group(function () {
Route::get('/{conversation}', [ConversationController::class, 'show'])
->name('conversations.show');
Route::post('/{conversation}/messages', [MessageController::class, 'store'])
->name('conversation-messages.store');
Route::get('/{conversation}/messages/{message}', [MessageController::class, 'show'])
->name('conversation-messages.show');
});
});
パラメーターを別のモデルクラスに解決したい場合は、明示的なバインディングを定義します。カスタムのバインディングロジックには、Route::bind() を使用するか、モデルに resolveRouteBinding() を実装します。
use App\Models\AiConversation;
use Illuminate\Support\Facades\Route;
Route::model('conversation', AiConversation::class);
サービスコンテナのバインディング
依存関係の結び付けを明確にするため、サービスプロバイダーでインターフェースを実装にバインドします。
use App\Repositories\EloquentOrderRepository;
use App\Repositories\OrderRepository;
use Illuminate\Support\ServiceProvider;
final class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(OrderRepository::class, EloquentOrderRepository::class);
}
}
Eloquent モデルのパターン
モデルの設定
final class Project extends Model
{
use HasFactory;
protected $fillable = ['name', 'owner_id', 'status'];
protected $casts = [
'status' => ProjectStatus::class,
'archived_at' => 'datetime',
];
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_id');
}
public function scopeActive(Builder $query): Builder
{
return $query->whereNull('archived_at');
}
}
カスタムキャストと値オブジェクト
厳密な型付けには、列挙型または値オブジェクトを使用します。
use Illuminate\Database\Eloquent\Casts\Attribute;
protected $casts = [
'status' => ProjectStatus::class,
];
protected function budgetCents(): Attribute
{
return Attribute::make(
get: fn (int $value) => Money::fromCents($value),
set: fn (Money $money) => $money->toCents(),
);
}
N+1 を防ぐための先行読み込み
$orders = Order::query()
->with(['customer', 'items.product'])
->latest()
->paginate(25);
複雑なフィルター向けのクエリーオブジェクト
final class ProjectQuery
{
public function __construct(private Builder $query) {}
public function ownedBy(int $userId): self
{
$query = clone $this->query;
return new self($query->where('owner_id', $userId));
}
public function active(): self
{
$query = clone $this->query;
return new self($query->whereNull('archived_at'));
}
public function builder(): Builder
{
return $this->query;
}
}
グローバルスコープとソフトデリート
デフォルトのフィルタリングにはグローバルスコープを、復元可能なレコードには SoftDeletes を使用します。
意図的に段階的な動作をさせる場合を除き、同じフィルターにはグローバルスコープか名前付きスコープのどちらか一方だけを使用してください。
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Builder;
final class Project extends Model
{
use SoftDeletes;
protected static function booted(): void
{
static::addGlobalScope('active', function (Builder $builder): void {
$builder->whereNull('archived_at');
});
}
}
再利用可能なフィルター向けのクエリースコープ
use Illuminate\Database\Eloquent\Builder;
final class Project extends Model
{
public function scopeOwnedBy(Builder $query, int $userId): Builder
{
return $query->where('owner_id', $userId);
}
}
// In service, repository etc.
$projects = Project::ownedBy($user->id)->get();
複数ステップの更新におけるトランザクション
use Illuminate\Support\Facades\DB;
DB::transaction(function (): void {
$order->update(['status' => 'paid']);
$order->items()->update(['paid_at' => now()]);
});
マイグレーション
命名規則
- ファイル名にはタイムスタンプを使用します:
YYYY_MM_DD_HHMMSS_create_users_table.php - マイグレーションには無名クラスを使用します(名前付きクラスは使いません)。ファイル名で目的を示します
- テーブル名にはデフォルトで
snake_caseの複数形を使用します
マイグレーションの例
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('orders', function (Blueprint $table): void {
$table->id();
$table->foreignId('customer_id')->constrained()->cascadeOnDelete();
$table->string('status', 32)->index();
$table->unsignedInteger('total_cents');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
};
フォームリクエストとバリデーション
バリデーションはフォームリクエストにまとめ、入力を DTO に変換します。
use App\Models\Order;
final class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('create', Order::class) ?? false;
}
public function rules(): array
{
return [
'customer_id' => ['required', 'integer', 'exists:customers,id'],
'items' => ['required', 'array', 'min:1'],
'items.*.sku' => ['required', 'string'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
];
}
public function toDto(): CreateOrderData
{
return new CreateOrderData(
customerId: (int) $this->validated('customer_id'),
items: $this->validated('items'),
);
}
}
API リソース
リソースとページネーションを使って、API レスポンスに一貫性を持たせます。
$projects = Project::query()->active()->paginate(25);
return response()->json([
'success' => true,
'data' => ProjectResource::collection($projects->items()),
'error' => null,
'meta' => [
'page' => $projects->currentPage(),
'per_page' => $projects->perPage(),
'total' => $projects->total(),
],
]);
イベント、ジョブ、キュー
- 副作用(メール、分析など)にはドメインイベントを発行します
- 時間のかかる処理(レポート、エクスポート、Webhook など)にはキュージョブを使用します
- リトライとバックオフに対応した、冪等なハンドラーを優先します
キャッシュ
- 読み取りの多いエンドポイントやコストの高いクエリーをキャッシュします
- モデルイベント(作成/更新/削除)でキャッシュを無効化します
- 関連データをキャッシュする場合は、簡単に無効化できるようタグを使用します
設定と環境
- シークレットは
.envに、設定はconfig/*.phpに保管します - 環境ごとの設定オーバーライドを使用し、本番環境では
config:cacheを実行します