mcpField
L
laravel-actions

laravel-actions

github.com/leeovery開発4341 回インストール31 週間前に更新
リポジトリ

laravel-actions について

Laravel 開発者向けに、ドメインロジックを単一責任で合成可能な Action として整理する方法を示すガイドです。コントローラー、ジョブ、リスナーは処理を委譲し、トランザクションやテストなどの規則も扱います。

主な機能

  1. 01呼び出し可能クラスで操作をカプセル化
  2. 02依存性注入による Action の合成
  3. 03ガードメソッドでビジネスルールを検証
  4. 04データ変更にデータベーストランザクションを使用
  5. 05ドメインエンティティ別に Action を整理
  6. 06AAA パターンによる Action のテスト

活用シーン

  1. 01コントローラーの業務ロジックを Action に移す
  2. 02複数段階の業務ワークフローを合成
  3. 03データ変更をトランザクションで保護
  4. 04Laravel の Action 構成とテストを標準化

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/leeovery/claude-laravel --skill laravel-actions

インストール後、Claude Code で/laravel-actionsを入力して読み込まれたか確認します。

詳細

Laravel アクション

アクションはドメインロジックの中核です。すべてのビジネス操作はアクションに実装します。

関連ガイド:

  • DTO - アクションにデータを渡すための DTO
  • コントローラー - コントローラーはアクションに処理を委譲します
  • モデル - アクションがアクセスするモデル
  • テスト - トリプル A パターンを使ったテスト

基本方針

コントローラー、ジョブ、リスナーにはドメインロジックを一切含めません。これらはアクションに処理を委譲するだけです。

アクションは以下のとおりです。

  • 呼び出し可能なクラス - __invoke() メソッドを 1 つだけ持ちます
  • 単一責任 - 各アクションは 1 つの処理だけを行います
  • 合成可能 - アクションがほかのアクションを呼び出してワークフローを構築します
  • ステートレス - 呼び出しごとに独立しています(ただし、呼び出しコンテキストは保存できます)
  • 型安全 - パラメーターと戻り値の型を厳密に指定します
  • トランザクション対応 - データベースの変更をトランザクションで囲みます

基本構造

<?php

declare(strict_types=1);

namespace App\Actions\Order;

use App\Data\CreateOrderData;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\DB;

class CreateOrderAction
{
    public function __invoke(User $user, CreateOrderData $data): Order
    {
        return DB::transaction(function () use ($user, $data) {
            $order = $this->createOrder($user, $data);
            $this->attachOrderItems($order, $data);

            return $order->fresh(['items']);
        });
    }

    private function createOrder(User $user, CreateOrderData $data): Order
    {
        return $user->orders()->create([
            'status' => $data->status,
            'notes' => $data->notes,
        ]);
    }

    private function attachOrderItems(Order $order, CreateOrderData $data): void
    {
        $order->items()->createMany(
            $data->items->map(fn ($item) => [
                'product_id' => $item->productId,
                'quantity' => $item->quantity,
                'price' => $item->price,
            ])->all()
        );
    }
}

主なパターン

1. アクションの合成における依存性注入

複雑なワークフローを構築するために、ほかのアクションを注入します。

class CreateOrderAction
{
    public function __construct(
        private readonly CalculateOrderTotalAction $calculateTotal,
        private readonly NotifyOrderCreatedAction $notifyOrderCreated,
    ) {}

    public function __invoke(User $user, CreateOrderData $data): Order
    {
        return DB::transaction(function () use ($user, $data) {
            $order = $this->createOrder($user, $data);

            // Compose with other actions
            $total = ($this->calculateTotal)($order);
            $order->update(['total' => $total]);

            ($this->notifyOrderCreated)($order);

            return $order->fresh();
        });
    }
}
2. 検証のためのガードメソッド

実行前にビジネスルールを検証します。

class CancelOrderAction
{
    public function __invoke(Order $order): Order
    {
        $this->guard($order);

        return DB::transaction(function () use ($order) {
            $order->updateToCancelled();
            $this->refundPayment($order);
            return $order;
        });
    }

    private function guard(Order $order): void
    {
        throw_unless(
            $order->canBeCancelled(),
            OrderException::cannotCancelOrder($order)
        );
    }
}
3. プライベートヘルパーメソッド

複雑な処理を、目的を絞った小さなプライベートメソッドに分割します。

public function __invoke(User $user, CreateApplicationData $data): Application
{
    return DB::transaction(function () use ($user, $data) {
        $application = $this->createApplication($user, $data);
        $this->createContacts($application, $data);
        $this->createAddresses($application, $data);
        $this->createDocuments($application, $data);

        return $application;
    });
}
4. コンテキスト用の読み取り専用プロパティ

パラメーターの受け渡しを避けるため、呼び出しコンテキストを読み取り専用プロパティに保存します。

class ProcessOrderAction
{
    private readonly Order $order;

    public function __invoke(Order $order): void
    {
        $this->order = $order;
        $this->guard();

        DB::transaction(function (): void {
            $this->processPayment();
            $this->updateInventory();
            $this->sendNotifications();
        });
    }

    private function guard(): void
    {
        throw_unless($this->order->isPending(), 'Order must be pending');
    }

    private function processPayment(): void
    {
        // Access $this->order without passing it
    }
}

命名規則

形式: {動詞}{エンティティ}Action

例:

  • CreateOrderAction
  • UpdateUserProfileAction
  • DeleteDocumentAction
  • CalculateOrderTotalAction
  • SendEmailNotificationAction
  • ProcessPaymentAction

アクションを作成するタイミング

✅ 次の場合はアクションを作成します。
  • すべてのドメイン操作(単純な CRUD を含む)
  • 複雑さを問わず、ビジネスロジックを実装する場合
  • 複数の場所で使う再利用可能な操作を構築する場合
  • 複数のステップを組み合わせてワークフローを構築する場合
  • ジョブやリスナーでドメインロジックを実行する必要がある場合
  • モデルまたはデータに触れるすべての操作
❌ 次の場合はアクションを作成しません。
  • 表示用の純粋なデータ取得(クエリやクエリビルダーを使います)
  • HTTP 固有の処理(ミドルウェアやコントローラーで扱います)
  • 書式設定やプレゼンテーションのロジック(リソースやトランスフォーマーを使います)

重要なルール: コントローラーにはドメインロジックを一切含めません。単純な $user->update($data) であっても、UpdateUserAction に処理を委譲します。

呼び出しパターン

依存性注入を使用
public function store(
    CreateOrderRequest $request,
    CreateOrderAction $action
) {
    $order = $action(user(), CreateOrderData::from($request));
    return OrderResource::make($order);
}
resolve() ヘルパーを使用
// In controllers
$order = resolve(CreateOrderAction::class)(
    user(),
    CreateOrderData::from($request)
);

// Inside another action
$result = resolve(ProcessPaymentAction::class)($order, $paymentData);

重要: 一貫性を保つため、app() ではなく resolve() を使います。

データベーストランザクション

データを変更する処理は必ずトランザクションで囲みます。

public function __invoke(CreateOrderData $data): Order
{
    return DB::transaction(function () use ($data) {
        $order = Order::create($data->toArray());
        $order->items()->createMany($data->items->toArray());

        return $order;
    });
}

エラー処理

ビジネスルール違反にはドメイン例外をスローします。

class CreateOrderAction
{
    public function __invoke(User $user, CreateOrderData $data): Order
    {
        if ($user->orders()->pending()->count() >= 5) {
            throw OrderException::tooManyPendingOrders($user);
        }

        return DB::transaction(function () use ($user, $data) {
            return $user->orders()->create($data->toArray());
        });
    }
}

アクションのテスト

ユニットテストでは、トリプル A パターンを使ってアクションを分離してテストします。

use function Pest\Laravel\assertDatabaseHas;

it('creates an order', function () {
    // Arrange
    $user = User::factory()->create();
    $data = CreateOrderData::testFactory()->make();

    // Act
    $order = resolve(CreateOrderAction::class)($user, $data);

    // Assert
    expect($order)->toBeInstanceOf(Order::class);
    assertDatabaseHas('orders', ['id' => $order->id]);
});

it('throws exception when user has too many pending orders', function () {
    // Arrange
    $user = User::factory()
        ->has(Order::factory()->pending()->count(5))
        ->create();
    $data = CreateOrderData::testFactory()->make();

    // Act & Assert
    expect(fn () => resolve(CreateOrderAction::class)($user, $data))
        ->toThrow(OrderException::class);
});

よく使うパターン

シンプルな CRUD アクション
class UpdateUserAction
{
    public function __invoke(User $user, UpdateUserData $data): User
    {
        $user->update($data->toArray());
        return $user->fresh();
    }
}
複数ステップのワークフロー
class OnboardUserAction
{
    public function __construct(
        private readonly CreateUserProfileAction $createProfile,
        private readonly SendWelcomeEmailAction $sendWelcome,
        private readonly AssignDefaultRoleAction $assignRole,
    ) {}

    public function __invoke(RegisterUserData $data): User
    {
        return DB::transaction(function () use ($data) {
            $user = User::create($data->toArray());

            ($this->createProfile)($user, $data->profileData);
            ($this->assignRole)($user);
            ($this->sendWelcome)($user);

            return $user;
        });
    }
}
外部サービスとの連携
class ProcessPaymentAction
{
    public function __construct(
        private readonly StripeService $stripe,
    ) {}

    public function __invoke(Order $order, PaymentData $data): Payment
    {
        $this->guard($order);

        return DB::transaction(function () use ($order, $data) {
            $stripePayment = $this->stripe->charge($data);

            $payment = $order->payments()->create([
                'amount' => $data->amount,
                'stripe_id' => $stripePayment->id,
                'status' => PaymentStatus::Completed,
            ]);

            $order->markAsPaid();

            return $payment;
        });
    }

    private function guard(Order $order): void
    {
        throw_if($order->isPaid(), 'Order already paid');
    }
}

アクションの整理

ドメインエンティティごとにグループ化します。

app/Actions/
├── Order/
│   ├── CreateOrderAction.php
│   ├── CancelOrderAction.php
│   ├── ProcessOrderAction.php
│   └── CalculateOrderTotalAction.php
├── User/
│   ├── CreateUserAction.php
│   ├── UpdateUserProfileAction.php
│   └── DeleteUserAction.php
└── Payment/
    ├── ProcessPaymentAction.php
    └── RefundPaymentAction.php

アクションの種類ごとにはグループ化しません(CreateActions/、UpdateActions/ などは避けます)。

マルチテナンシー

Central 用と Tenanted 用のアクションを分けます。

app/Actions/
├── Central/
│   ├── CreateTenantAction.php
│   └── ProvisionDatabaseAction.php
└── Tenanted/
    ├── CreateOrderAction.php
    └── UpdateUserAction.php

包括的なパターンについては、マルチテナンシーを参照してください。

コメント