Overview
Actions are single-purpose classes that encapsulate business logic, following the Command pattern with a single execute() method. They serve as the primary location for domain logic in the application.
What Actions Are
An action is a dedicated class responsible for one specific operation. Rather than mixing business logic into controllers, models, or Livewire components, actions provide a clean, focused home for that logic.
Actions typically handle:
- Domain operations - Creating, updating, or deleting entities with business rules
- Complex algorithms - Multi-step processes like scoring, matching, or calculations
- Data retrieval - Query wrappers with caching, filtering, or transformation
- Data transformation - Converting between formats, units, or structures
- Orchestration - Coordinating multiple smaller actions into a workflow
Why Use Actions
Separation of concerns - Controllers handle HTTP, Livewire handles UI state, models handle persistence. Actions handle what the application does. This keeps each layer focused on its responsibility.
Reusability - The same action can be called from a web controller, API endpoint, Livewire component, queued job, or artisan command. The logic lives in one place.
Testability - Actions are easy to unit test because they don't depend on HTTP context, session state, or UI frameworks. Pass in data, get a result.
Encapsulation - Complex logic stays contained. A 200-line date calculation algorithm lives in one action, not scattered across controllers. Caching strategies, event dispatching, and validation rules are hidden from callers.
Composability - Actions can call other actions. A ProcessOrder action might orchestrate ValidateInventory, CreatePayment, and SendConfirmation actions in sequence.
When to Create an Action
Create an action when:
- Logic is used in multiple places
- Logic requires more than a few lines
- Logic has business rules or validation
- Logic has side effects (events, notifications, cache)
- Logic needs independent testing
Skip actions for trivial operations:
// Too simple - just use the model directly
$user->update(['name' => $name]);
// Worth an action - has validation, events, logging
app(ChangeUserEmail::class)->execute($user, $newEmail);
Basic Structure
<?php
declare(strict_types=1);
namespace App\Actions\Tickets;
use App\Data\TicketData;
use App\Models\Ticket;
final class CreateTicket
{
public function execute(TicketData $data): Ticket
{
return Ticket::create($data->toArray());
}
}
Key Principles
Single Responsibility
Each action does one thing:
// Good - Single purpose
final class CreateTicket
{
public function execute(TicketData $data): Ticket
{
return Ticket::create($data->toArray());
}
}
final class UpdateTicket
{
public function execute(int|string|Ticket $ticket, TicketData $data): void
{
if (!$ticket instanceof Ticket) {
$ticket = Ticket::findOrFail((int) $ticket);
}
$ticket->update($data->toArray());
}
}
final class DeleteTicket
{
public function execute(Ticket $ticket): void
{
$ticket->delete();
}
}
// Bad - Multiple responsibilities
final class TicketManager
{
public function create() { }
public function update() { }
public function delete() { }
public function archive() { }
}
Use DTOs for Complex Parameters
// Good - DTO for multiple related fields
final class CreateTicket
{
public function execute(TicketData $data): Ticket
{
return Ticket::create($data->toArray());
}
}
// Acceptable for simple operations
final class ActivateUser
{
public function execute(User $user): void
{
$user->update(['is_active' => true]);
}
}
// Good - Flexible input types
final class UpdateItem
{
public function execute(int|string|Item $item, ItemData $data): void
{
if (!$item instanceof Item) {
$item = Item::findOrFail((int) $item);
}
$item->update($data->toArray());
}
}
Return Types
// Creating - return the model
final class CreateTicket
{
public function execute(TicketData $data): Ticket
{
return Ticket::create($data->toArray());
}
}
// Updating/Deleting - return void
final class UpdateTicket
{
public function execute(Ticket $ticket, TicketData $data): void
{
$ticket->update($data->toArray());
}
}
// Calculations - return the result
final class ConvertKilogramsToPounds
{
public function execute(float $kg): float
{
return (float) Number::format($kg * 2.20462262, 2);
}
}
// Queries - return collection or model
final class GetAllActiveRepeatingReminders
{
public function execute(): Collection
{
return Reminder::query()
->where('is_repeating', true)
->where('is_active', true)
->get();
}
}
Directory Organization
Use directories to separate different concerns or concepts.
app/Actions/
├── Tickets/
│ ├── CreateTicket.php
│ ├── UpdateTicket.php
│ └── DeleteTicket.php
├── Users/
│ ├── CreateUser.php
│ ├── UpdateUser.php
│ └── BuildUserSettingDefaults.php
├── Items/
│ ├── CreateItem.php
│ └── UpdateItem.php
└── Conversions/
├── ConvertKilogramsToPounds.php
└── ConvertPoundsToKilograms.php
Naming Conventions
Verb-First Names
// CRUD operations
CreateTicket
UpdateTicket
DeleteTicket
// Specific actions
ActivateUser
DeactivateUser
ArchiveProject
RestoreProject
// Data retrieval
GetAllActiveReminders
FindUserByEmail
BuildUserSettingDefaults
// Conversions/Calculations
ConvertKilogramsToPounds
CalculateOrderTotal
GenerateInvoiceNumber
Dependency Injection
Constructor Injection for Dependencies
final class CreateTicket
{
public function __construct(
private readonly NotificationService $notifications,
private readonly AuditLogger $auditLogger,
) {}
public function execute(TicketData $data): Ticket
{
$ticket = Ticket::create($data->toArray());
$this->notifications->sendTicketCreated($ticket);
$this->auditLogger->log('ticket.created', $ticket);
return $ticket;
}
}
Using the Container
// In controllers/Livewire
$ticket = app(CreateTicket::class)->execute($data);
// With dependency injection
public function store(CreateTicket $createTicket, TicketData $data): Response
{
$ticket = $createTicket->execute($data);
return redirect()->route('tickets.show', $ticket);
}
Complex Actions
Multi-Step Operations
final class ProcessOrder
{
public function __construct(
private readonly ValidateInventory $validateInventory,
private readonly CreatePayment $createPayment,
private readonly SendConfirmation $sendConfirmation,
) {}
public function execute(OrderData $data): Order
{
// Step 1: Validate
$this->validateInventory->execute($data->items);
// Step 2: Create order
$order = Order::create($data->toArray());
// Step 3: Process payment
$this->createPayment->execute($order);
// Step 4: Notify
$this->sendConfirmation->execute($order);
return $order;
}
}
With Database Transactions
final class TransferFunds
{
public function execute(Account $from, Account $to, float $amount): void
{
DB::transaction(function () use ($from, $to, $amount): void {
$from->decrement('balance', $amount);
$to->increment('balance', $amount);
Transaction::create([
'from_account_id' => $from->id,
'to_account_id' => $to->id,
'amount' => $amount,
]);
});
}
}
Testing Actions
<?php
declare(strict_types=1);
use App\Actions\Tickets\CreateTicket;
use App\Data\TicketData;
use App\Models\Ticket;
use App\Models\User;
test('can create a new ticket', function (): void {
$user = User::factory()->create();
$data = new TicketData(
title: 'Test Ticket',
body: 'This is a test ticket.',
user_id: $user->id,
);
$ticket = app(CreateTicket::class)->execute($data);
expect($ticket)
->toBeInstanceOf(Ticket::class)
->title->toBe('Test Ticket')
->body->toBe('This is a test ticket.')
->user_id->toBe($user->id);
});
test('can update an existing ticket', function (): void {
$ticket = Ticket::factory()->create();
$data = new TicketData(
title: 'Updated Title',
body: 'Updated body.',
user_id: $ticket->user_id,
);
app(UpdateTicket::class)->execute($ticket, $data);
expect($ticket->fresh())
->title->toBe('Updated Title')
->body->toBe('Updated body.');
});