DEV Community

Cover image for Laravel Action Classes: Single-Purpose Executors Done Right
Shakil Alam
Shakil Alam

Posted on Originally published at blog.shakiltech.com

Laravel Action Classes: Single-Purpose Executors Done Right

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
    {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

The neurax-host codebase uses execute():

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

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',
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

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!');
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode
// In an Action
if ($requested > $available) {
    throw new CapacityLimitExceededException($requested, $available);
}
Enter fullscreen mode Exit fullscreen mode

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()]);
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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. CreateOrderAction should 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 →

Top comments (0)