mcpField
L
laravel-actions

laravel-actions

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

laravel-actions について

Laravel 開発者向けに、ドメイン操作を単一責任の呼び出し可能な Action クラスとして構成する開発スキルです。コントローラー、ジョブ、リスナーから業務ロジックを分離するための、合成・トランザクション・テストのパターンを扱います。

主な機能

  1. 01単一責任の呼び出し可能な Action クラス
  2. 02依存性注入による Action の合成
  3. 03実行前の業務ルール検査
  4. 04データ変更のトランザクション化
  5. 05Triple-A 単体テストパターン

活用シーン

  1. 01コントローラーからドメインロジックを分離
  2. 02複数段階の業務ワークフローを構成
  3. 03トランザクション内でモデルを変更
  4. 04個別の業務操作をテスト

インストール

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

$npx skills add https://github.com/leeovery/agentic-skills --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

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

コメント