Action Classes — The Single-Purpose Executor
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:
- Pre-condition check with
throw_if() DB::beginTransaction()- All writes inside try/catch
DB::commit()on successDB::rollBack()+ rethrow on failure- 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()orhandle()). - 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 genericException. final readonlyon 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(), noredirect(), noresponse(). An Action should work identically whether called from a controller, a queue job, a test, or an Artisan command. The moment you addrequest(), 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 alsoSendOrderConfirmation— 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
- Laravel Docs: Database Transactions
- Loris Leiva: Laravel Actions — a popular Action-first approach
- Laravel Docs: Events
3 Comments