laravel-actions について
Laravel 開発者向けに、ドメイン操作を単一責任の呼び出し可能な Action クラスとして構成する開発スキルです。コントローラー、ジョブ、リスナーから業務ロジックを分離するための、合成・トランザクション・テストのパターンを扱います。
主な機能
- 01単一責任の呼び出し可能な Action クラス
- 02依存性注入による Action の合成
- 03実行前の業務ルール検査
- 04データ変更のトランザクション化
- 05Triple-A 単体テストパターン
活用シーン
- 01コントローラーからドメインロジックを分離
- 02複数段階の業務ワークフローを構成
- 03トランザクション内でモデルを変更
- 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
例:
CreateOrderActionUpdateUserProfileActionDeleteDocumentActionCalculateOrderTotalActionSendEmailNotificationActionProcessPaymentAction
アクションを作成するタイミング
✅ 次の場合はアクションを作成します。
- すべてのドメイン操作(単純な 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
包括的なパターンについては、マルチテナンシーを参照してください。