Back to Blog
The Actions Pattern in Laravel: Clean, Testable Business Logic
Beyond Fat Controllers
As applications grow, controllers become bloated with business logic. Services help, but often become dumping grounds themselves. Actions offer a more focused approach: one class, one action, one responsibility.
The Action Pattern
class CreateOrder
{
public function __construct(
private PaymentGateway $payments,
private InventoryService $inventory,
private Notifier $notifier
) {}
public function execute(User $user, array $items): Order
{
// Validate inventory
$this->inventory->reserve($items);
// Create order
$order = DB::transaction(function () use ($user, $items) {
$order = Order::create([
'user_id' => $user->id,
'total' => $this->calculateTotal($items),
]);
foreach ($items as $item) {
$order->items()->create($item);
}
return $order;
});
// Process payment
$this->payments->charge($user, $order->total);
// Send notifications
$this->notifier->orderCreated($order);
return $order;
}
private function calculateTotal(array $items): float
{
return collect($items)->sum(fn ($item) =>
$item['price'] * $item['quantity']
);
}
}
Using Actions
class OrderController extends Controller
{
public function store(
OrderRequest $request,
CreateOrder $createOrder
): RedirectResponse {
$order = $createOrder->execute(
$request->user(),
$request->validated()['items']
);
return redirect()->route('orders.show', $order);
}
}
Testing Actions
class CreateOrderTest extends TestCase
{
public function test_creates_order_with_items(): void
{
$user = User::factory()->create();
$product = Product::factory()->create(['price' => 10]);
$action = app(CreateOrder::class);
$order = $action->execute($user, [
['product_id' => $product->id, 'quantity' => 2, 'price' => 10],
]);
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
'total' => 20,
]);
$this->assertCount(1, $order->items);
}
public function test_charges_payment(): void
{
$mock = $this->mock(PaymentGateway::class);
$mock->shouldReceive('charge')->once()->with(
Mockery::type(User::class),
100
);
$action = app(CreateOrder::class);
$action->execute($this->user, $this->items);
}
}
Actions as Jobs
class ProcessRefund implements ShouldQueue
{
use AsAction;
public function handle(Order $order): void
{
$this->refundPayment($order);
$this->restoreInventory($order);
$this->notifyCustomer($order);
}
public function asJob(Order $order): void
{
$this->handle($order);
}
}
// Dispatch as job
ProcessRefund::dispatch($order);
When to Use Actions
- Complex business operations with multiple steps
- Logic that needs to be reused across controllers
- Operations that require transaction handling
- Logic that benefits from isolated testing
Conclusion
Actions provide a clean, focused approach to organizing business logic. One class, one responsibility, easy to test, easy to understand. Start using them for your next complex feature.
Related Articles
Need Help With Your Project?
I respond to all inquiries within 24 hours. Let's discuss how I can help build your production-ready system.
Get In Touch