Key Concepts

Actions

How to write Actions — the smallest reusable unit of business logic in the codebase — and the rules that keep them consistent and safe.

An Action is a small class that does one thing: create an order, look up a provider, recalculate a balance. It has no constructor, no state, and one entry point — a public static function handle(). Every Action lives in src/Domain/{Domain}/Actions/{ActionName}.php, one class per file.

You wrap operations in Actions so they can be called from anywhere — a Service, a controller, a job, a console command, another Action — without dragging the rest of the framework along. This is what lets Services stay thin and tests stay fast.

If an operation needs to coordinate two or more Actions, the orchestration itself is also an Action. Actions compose; they don't grow.

The shape

final class GetEmployee
{
    public static function handle(int $id): ?Admin
    {
        return Admin::where('id', $id)->first();
    }
}

GetEmployee::handle($id);

Three things to notice: the class is final (no subclassing), there's exactly one public method (handle), and the class name reads as a verb phrase — GetEmployee, not EmployeeAction or EmployeeGetter. You call it statically with ::handle(...), never with new.

Naming

CRUD Actions use the standard verbs: Get{Entity}, Get{Entity}s, Create{Entity}, Update{Entity}, Delete{Entity}. Domain operations use the natural verb for the work: CalculateOrderTotal, ApproveQuotation, RecalculateProviderBalance, IssueWaybill.

Drop the Action suffix. The namespace Domain\{Domain}\Actions\ already says it's an Action — adding Action to the class name is noise. Two domains can have an Action with the same short name; each lives in its own namespace.

// ✅ Good
final class CreateOrder { /* ... */ }
final class ApproveQuotation { /* ... */ }
// ❌ Bad
final class CreateOrderAction { /* ... */ }
final class OrderApprover { /* ... */ }

One handle, no constructor

Actions are stateless. The only public method is public static function handle(...). There is no constructor and there are no instance properties. You never write new SomeAction().

// ✅ Good
final class GetEmployee
{
    public static function handle(int $id): ?Admin
    {
        return Admin::findOrFail($id);
    }
}
// ❌ Bad — instantiated, non-static, named execute()
class CreateOrderAction
{
    public function execute(CreateOrderData $data) { /* ... */ }
}

(new CreateOrderAction())->execute($data);

The bad version breaks the contract every other piece of code relies on. Callers have to know whether to instantiate or call statically, the method name varies, and stateful instance properties creep in over time.

Wrap multi-statement writes in DB::transaction

Any Action that performs more than one write wraps the body in DB::transaction(fn () => ...). This is non-negotiable. Read-only Actions (GetEmployee, GetProviders) do not need a transaction.

Without the wrapper, a failure between two writes leaves the database in a half-mutated state — an order with no lines, an invoice with no transactions, a row updated but its audit log never written. The bug only surfaces under load when a constraint violation or deadlock interrupts the second statement.

// ❌ Bad — partial write on failure
final class CreateOrder
{
    public static function handle(CreateOrderData $data): Order
    {
        $order = Order::create([...]);
        $order->lines()->createMany([...]);

        return $order;
    }
}
// ✅ Good — atomic
final class CreateOrder
{
    public static function handle(CreateOrderData $data): Order
    {
        return DB::transaction(function () use ($data) {
            $order = Order::create([...]);
            $order->lines()->createMany([...]);

            return $order;
        });
    }
}
Skipping the transaction is the most expensive Action mistake to debug — the data corruption is silent and only appears in production. If your Action writes more than once, wrap it.

Resolve dependencies inside handle()

Actions have no constructor, so any service or contract you need is resolved inside handle() via app(). Cross-domain calls go through the upstream Contract — never reach into another domain's Models or Actions directly.

// ✅ Good
final class CreateOrder
{
    public static function handle(CreateOrderData $data): Order
    {
        return DB::transaction(function () use ($data) {
            $provider = app(ProviderContract::class)->getProvider($data->provider_id);

            return Order::create([
                'provider_id' => $provider->id,
                'customer_id' => $data->customer_id,
                'amount' => $data->amount,
            ]);
        });
    }
}
// ❌ Bad — constructor injection on an Action
final class CreateOrder
{
    public function __construct(private ProviderContract $providers) {}

    public function handle(CreateOrderData $data): Order { /* ... */ }
}

Resolving dependencies inside handle() keeps the Action static and lets tests swap any Contract in the container without rewriting the Action. A reviewer reading CreateOrder sees the dependency at the call site, not in a constructor at the top of the file.

This contradicts the generic Laravel guidance of "always use constructor injection." Inside domain Actions and Services we explicitly override it — see Services and contracts. Controllers and other framework-managed classes keep constructor injection.

Where to place Actions

Every Action lives at src/Domain/{Domain}/Actions/{ActionName}.php. One class per file, no exceptions.

Within the same domain, import the Action class directly — you own that code. Across domains, you must go through the upstream Contract. See Domain boundaries for the rule and rationale.

// ✅ Same domain — direct import
use Domain\Order\Actions\CalculateOrderTotal;

$total = CalculateOrderTotal::handle($order);
// ❌ Cross-domain — never reach in
$provider = \Domain\Provider\Actions\GetProvider::handle($id);

// ✅ Cross-domain — go through the Contract
$provider = app(ProviderContract::class)->getProvider($id);

Reaching into another domain's Action couples your code to its internals. The Contract is what's public; everything behind it is free to change.

Common Action templates

These are the four shapes you'll write most often. Copy them as starting points.

Get{Entity} — single record lookup

final class GetEmployee
{
    public static function handle(int $id): ?Admin
    {
        return Admin::findOrFail($id);
    }
}

Get{Entity}s — filtered, paginated list

Extends GetDataAbstract so the Service can call ::for($filters, $with, $withCount) and the caller decides whether to take a pagination or a collection.

final class GetProviders extends GetDataAbstract
{
    public function query(): Builder
    {
        return Provider::filterBy($this->data);
    }
}

The filterBy macro on the Model comes from the {Entity}Filter trait in Models/Scopes/. See Eloquent and queries.

Create{Entity} — DTO in, model out

Wrap in a transaction once it touches more than one statement.

final class CreateOrder
{
    public static function handle(CreateOrderData $data): Order
    {
        return DB::transaction(function () use ($data) {
            return Order::create($data->toArray());
        });
    }
}

Update{Entity} — null-safe partial update

Arr::whereNotNull strips unset DTO properties so optional fields don't blank existing columns. Mark final readonly since the class has no state.

final readonly class UpdateOrder
{
    public static function handle(Order $order, UpdateOrderData $data): Order
    {
        $order->update(Arr::whereNotNull($data->toArray()));

        return $order;
    }
}

Orchestrating multiple Actions

When an operation needs to coordinate two or more Actions — "create an order, record the accounting transaction, dispatch a notification" — that orchestration is itself an Action.

final class PlaceOrder
{
    public static function handle(CreateOrderData $data): Order
    {
        return DB::transaction(function () use ($data) {
            $order = CreateOrder::handle($data);

            app(AccountingContract::class)->recordTransaction($order->id, $data->total_amount);
            NotifyCustomerOfOrder::dispatch($order->id);

            return $order;
        });
    }
}

The Service still has nothing in it beyond a delegate-to-Action call. The complexity lives in the Action, where it's testable and reusable.

Anti-patterns to avoid

These are the four that come up most often in code review:

  • Non-static Action with execute() — Actions must be final classes with public static function handle(...). (anti-patterns #6)
  • Missing DB::transaction on a multi-statement write — partial writes corrupt state on failure. (anti-patterns #8)
  • Constructor-injected service on an Action — Actions have no constructor. Resolve via app(SomeContract::class) inside handle(). (anti-patterns #7)
  • Reaching into another domain's Action directly — cross-domain calls go through the Contract, not the Action class. See Domain boundaries.