Architecture

Action Standards

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.');
});