Action Classes — The Single-Purpose Executor

Action Classes — The Single-Purpose Executor

Reading Time: 4 minutes

Series: Every Laravel Project Should Have These Building Blocks 
Part: 9 of 35 | Level: Intermediate | Prerequisites: Service Classes


What You’ll Learn

  • What an Action class is and why it’s different from a Service
  • The execute() / handle() naming convention
  • How to wrap Actions in DB transactions
  • How to fire domain events after a successful Action
  • How to throw meaningful domain exceptions on failure
  • Real examples from production codebases

What Is an Action?

An Action is a class that does one thing. It has a single public method. It is called once per request or job. It either succeeds completely or fails cleanly.

The concept comes from the idea that “create an order”, “reject a service request”, or “mark an enrolment as paid” are distinct, named operations in your business domain. They deserve their own class.

Compare:

  • Service — OrderService::processPayment()OrderService::calculateDiscount()OrderService::generateInvoice(). One class, multiple responsibilities.
  • Action — CreateOrderAction::execute(). One class, one responsibility.

Actions work well when an operation:

  • Wraps a database transaction
  • Has clear pre/post conditions
  • Should fire a domain event on success
  • Can fail in named ways

The Naming Convention: handle() vs execute()

Both conventions exist across these projects. The dp-sampling codebase uses handle():

// dp-sampling style
final class RejectServiceRequest
{
    public function handle(ServiceRequest $serviceRequest, string $reason): void
    {
        // ...
    }
}

The neurax-host codebase uses execute():

// neurax-host style
final readonly class CreateOrderAction
{
    public function execute(User $user, OrderCustomerData $data, Collection $cartItems): Order
    {
        // ...
    }
}

handle() is more Laravel-idiomatic (Jobs use handle()). execute() is clearer when reading call sites — $createOrder->execute(...)reads more naturally as a deliberate invocation.

Pick one and be consistent within a project. We’ll use execute() in examples throughout this article.


A Minimal Action

<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\User;
use App\Models\Subscription;

final readonly class ActivateSubscriptionAction
{
    public function execute(User $user, string $planId): Subscription
    {
        return Subscription::create([
            'user_id'    => $user->id,
            'plan_id'    => $planId,
            'starts_at'  => now(),
            'expires_at' => now()->addYear(),
            'status'     => 'active',
        ]);
    }
}

Call it from a controller:

class SubscriptionController extends Controller
{
    public function __construct(
        private readonly ActivateSubscriptionAction $activate
    ) {}

    public function store(StoreSubscriptionRequest $request): RedirectResponse
    {
        $subscription = $this->activate->execute(
            $request->user(),
            $request->validated('plan_id')
        );

        return redirect()->route('dashboard')->with('success', 'Subscription activated!');
    }
}

DB Transactions in Actions

When an Action performs multiple writes, wrap them in a transaction. If any step fails, everything rolls back:

// app/Actions/ServiceRequest/RejectServiceRequest.php
final class RejectServiceRequest
{
    /**
     * @throws Throwable
     */
    public function handle(
        ServiceRequest $serviceRequest,
        string $rejectionReason,
        ?Model $rejectedBy = null
    ): void {
        throw_if(
            $serviceRequest->alreadyRejected(),
            Exception::class,
            'Service request is already rejected.'
        );

        DB::beginTransaction();

        try {
            $serviceRequest->rejected_at = now();
            $serviceRequest->rejection_reason = $rejectionReason;
            $serviceRequest->completed_at = null;
            $serviceRequest->plan_created_at = null;
            $serviceRequest->rejectedBy()->associate($rejectedBy ?? $this->inferRejectedBy());
            $serviceRequest->save();

            // Delete all associated tasks
            $serviceRequest->tasks()->forceDelete();

            DB::commit();
        } catch (Throwable $throwable) {
            DB::rollBack();
            throw $throwable; // let it bubble up
        }

        // Fire the event AFTER the commit — the data is persisted
        event(new ServiceRequestRejected($serviceRequest));
    }

    private function inferRejectedBy(): ?Model
    {
        if (auth('merchandiser')->check()) {
            return auth('merchandiser')->user();
        }
        if (auth('admin')->check()) {
            return auth('admin')->user();
        }
        return null;
    }
}

Notice the pattern:

  1. Pre-condition check with throw_if()
  2. DB::beginTransaction()
  3. All writes inside try/catch
  4. DB::commit() on success
  5. DB::rollBack() + rethrow on failure
  6. Fire domain event after commit — outside the transaction

Step 6 is critical. If you fire an event inside the transaction and the event listener tries to read the data, it might read stale data (the transaction isn’t committed yet on replicas). Always fire events after the transaction commits.


Using Laravel’s DB::transaction() Shorthand

For simpler Actions, use the closure form:

final readonly class CreateOrderAction
{
    public function execute(User $user, OrderCustomerData $data): Order
    {
        $order = DB::transaction(function () use ($user, $data): Order {
            $order = Order::create([
                'user_id'    => $user->id,
                'email'      => $data->email,
                'first_name' => $data->firstName,
                'last_name'  => $data->lastName,
                'total'      => $data->calculateTotal(),
            ]);

            $this->attachLineItems($order, $data);

            return $order;
        });

        // Event fired after the transaction
        event(new OrderPlaced($order));

        return $order;
    }
}

Domain Exceptions in Actions

Don’t throw generic Exception. Create named domain exceptions that communicate what went wrong:

// app/Exceptions/CapacityLimitExceededException.php
final class CapacityLimitExceededException extends RuntimeException
{
    public function __construct(int $requested, int $available)
    {
        parent::__construct(
            "Cannot assign {$requested} units. Only {$available} available."
        );
    }
}

// app/Exceptions/CapacityShortageException.php
final class CapacityShortageException extends RuntimeException {}
// In an Action
if ($requested > $available) {
    throw new CapacityLimitExceededException($requested, $available);
}

In controllers or exception handlers, you can catch the specific type and return an appropriate response:

try {
    $this->assignCapacity->execute($task, $amount);
} catch (CapacityLimitExceededException $e) {
    return back()->withErrors(['capacity' => $e->getMessage()]);
}

final readonly Actions

Use final readonly for Actions when they don’t need to be extended or mutated:

final readonly class MarkEnrolmentPaidAction
{
    public function __construct(
        private LedgerService $ledger,
        private NotificationService $notifications,
    ) {}

    public function execute(Enrolment $enrolment, Payment $payment): void
    {
        DB::transaction(function () use ($enrolment, $payment): void {
            $enrolment->update(['status' => EnrolmentStatus::Paid]);
            $this->ledger->recordPayment($enrolment, $payment);
        });

        $this->notifications->notifyEnrolmentPaid($enrolment);
    }
}

final prevents inheritance (Actions shouldn’t be extended — create new Actions). readonly prevents property mutation after construction.


Multi-Step Actions with Substeps

When an Action has multiple logical steps, extract them as private methods:

final class CheckoutAction
{
    public function execute(User $user, OrderCustomerData $data): Order
    {
        $cartItems = $this->validateCart($user);
        $order     = $this->createOrder($user, $data, $cartItems);
        $this->processPayment($order, $data->paymentReferenceId);
        $this->clearCart($user);

        event(new OrderPlaced($order));

        return $order;
    }

    private function validateCart(User $user): Collection
    {
        $cartItems = Cart::for($user)->items();

        if ($cartItems->isEmpty()) {
            throw new EmptyCartException('Cannot checkout with an empty cart.');
        }

        return $cartItems;
    }

    private function createOrder(User $user, OrderCustomerData $data, Collection $items): Order
    {
        return DB::transaction(fn () => Order::create([
            'user_id' => $user->id,
            'total'   => $items->sum('price'),
            // ...
        ]));
    }

    private function processPayment(Order $order, string $paymentId): void
    {
        $this->stripe->confirm($paymentId);
        $order->update(['payment_status' => PaymentStatus::Paid]);
    }

    private function clearCart(User $user): void
    {
        Cart::for($user)->clear();
    }
}

Key Takeaways

  • An Action does one thing and has one public method (execute() or handle()).
  • Wrap multi-write operations in a DB transaction. Fire domain events after the transaction commits.
  • Pre-condition checks with throw_if() keep the happy path linear.
  • Use domain exceptions (CapacityLimitExceededException) instead of generic Exception.
  • final readonly on Actions prevents accidental inheritance and mutation.
  • Extract substeps as private methods to keep the main execute() method readable.


Frequently Asked Questions

Can Actions call other Actions? Yes, but keep it shallow. CheckoutAction can call CreateOrderAction. If you find yourself chaining 4+ Actions, consider whether those sub-actions should become private methods inside a single Action instead.

What if my Action needs to send an email? Don’t put it in the Action. Fire a domain event (event(new OrderPlaced($order))) and let an Event Listener send the email. This keeps the Action free of side-effect logic and lets you add or remove notifications without touching the Action.

Should Actions be resolved from the container or instantiated with new? Both work. Container injection ($this->createOrder->execute(...) via constructor) is cleaner for production controllers. (new CreateOrderAction($dep))->execute(...) is fine in one-off scripts or tests. Pick the container approach consistently in controllers.


Tips and Gotchas

⚠️ Warning: Don’t put HTTP concerns in Actions — no request(), no redirect(), no response(). An Action should work identically whether called from a controller, a queue job, a test, or an Artisan command. The moment you add request(), it breaks in non-HTTP contexts.

💡 Tip: If an Action is growing beyond 50 lines, it’s probably doing two things. Split it. CreateOrderActionshould not also SendOrderConfirmation — that’s a listener’s job, triggered by the event the Action dispatches.

🔥 Expert Note: Fire domain events AFTER the database transaction commits, not inside it. DB::afterCommit(fn() => event(new OrderPlaced($order))) ensures the event only fires if the write succeeded. Listeners that read that order from the DB will find it committed and consistent.

Further Reading


← Service Classes | Next: DTOs →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

3 Comments