The Laravel Actions Pattern: Single-Purpose Classes That Do One Thing Well

· 8 min read

You've heard "keep controllers thin." You've been told to use Services. But Services often become God classes — UserService with 40 methods handling everything from registration to password resets.

Actions are the fix: one class per operation. CreateUser, SendInvoice, PublishPost. Each does exactly one thing.

The Problem with Services

// This always happens
class UserService
{
    public function register(array $data) { ... }
    public function login(array $data) { ... }
    public function updateProfile(User $user, array $data) { ... }
    public function changePassword(User $user, string $password) { ... }
    public function deleteAccount(User $user) { ... }
    public function exportData(User $user) { ... }
    public function importFromCsv(string $path) { ... }
    public function sendVerificationEmail(User $user) { ... }
    public function generateApiToken(User $user) { ... }
    // 30 more methods...
}

This violates the Single Responsibility Principle. It's hard to test, hard to navigate, and grows forever.

Actions: One Class, One Job

// app/Actions/CreateUser.php

namespace App\Actions;

use App\Models\User;
use Illuminate\Support\Facades\Hash;

class CreateUser
{
    public function execute(string $name, string $email, string $password): User
    {
        return User::create([
            'name' => $name,
            'email' => Hash::make($email),
            'password' => Hash::make($password),
        ]);
    }
}

That's it. No interface to implement. No abstract class. Just a plain PHP class with an execute method.

Using Actions

In Controllers

class RegistrationController extends Controller
{
    public function store(
        RegisterRequest $request,
        CreateUser $createUser,
        SendWelcomeEmail $sendWelcomeEmail,
    ) {
        $user = $createUser->execute(
            name: $request->name,
            email: $request->email,
            password: $request->password,
        );

        $sendWelcomeEmail->execute($user);

        Auth::login($user);

        return redirect('/dashboard');
    }
}

In Artisan Commands

class ImportUsersCommand extends Command
{
    protected $signature = 'users:import {file}';

    public function handle(CreateUser $createUser): int
    {
        $rows = // ... read CSV

        foreach ($rows as $row) {
            $createUser->execute($row['name'], $row['email'], $row['password']);
        }

        $this->info('Import complete.');
        return Command::SUCCESS;
    }
}

In Other Actions (Composition)

class RegisterUserForEvent
{
    public function __construct(
        private CreateUser $createUser,
        private CreateEventRegistration $createRegistration,
        private SendConfirmationEmail $sendConfirmation,
    ) {}

    public function execute(array $userData, Event $event): Registration
    {
        $user = $this->createUser->execute(...$userData);
        $registration = $this->createRegistration->execute($user, $event);
        $this->sendConfirmation->execute($registration);

        return $registration;
    }
}

Practical Examples

Action with Dependencies

// app/Actions/PublishPost.php

namespace App\Actions;

use App\Models\Post;
use App\Services\IndexNowService;
use Illuminate\Support\Facades\Cache;

class PublishPost
{
    public function __construct(
        private IndexNowService $indexNow,
    ) {}

    public function execute(Post $post): Post
    {
        $post->update([
            'published_at' => now(),
            'is_published' => true,
        ]);

        Cache::forget("post_{$post->slug}");

        $this->indexNow->submit($post->url);

        return $post;
    }
}

Action with Validation

Two approaches:

Approach 1: Validation in Form Request (recommended for HTTP)

// Form Request handles validation
class CreatePostRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'body' => ['required', 'string', 'min:100'],
            'tags' => ['array'],
        ];
    }
}

// Action receives already-validated data
class CreatePost
{
    public function execute(User $author, string $title, string $body, array $tags = []): Post
    {
        $post = $author->posts()->create([
            'title' => $title,
            'slug' => Str::slug($title),
            'body' => $body,
        ]);

        $post->tags()->sync($tags);

        return $post;
    }
}

Approach 2: Self-validating Actions (when called from multiple contexts)

class CreatePost
{
    public function execute(User $author, array $data): Post
    {
        $validated = Validator::make($data, [
            'title' => ['required', 'string', 'max:255'],
            'body' => ['required', 'string', 'min:100'],
            'tags' => ['array'],
        ])->validate();

        $post = $author->posts()->create([
            'title' => $validated['title'],
            'slug' => Str::slug($validated['title']),
            'body' => $validated['body'],
        ]);

        if (!empty($validated['tags'])) {
            $post->tags()->sync($validated['tags']);
        }

        return $post;
    }
}

Recommendation: Use Form Request for HTTP requests, DTOs for internal calls. Self-validating only when the action must work across different contexts (controller, command, job).

Data Transfer Objects (DTOs)

When an action has many parameters, use a DTO instead of a long parameter list:

// app/DataTransferObjects/CreateUserData.php

class CreateUserData
{
    public function __construct(
        public readonly string $name,
        public readonly string $email,
        public readonly string $password,
        public readonly ?string $avatarPath = null,
        public readonly string $timezone = 'UTC',
    ) {}

    public static function fromRequest(RegisterRequest $request): self
    {
        return new self(
            name: $request->validated('name'),
            email: $request->validated('email'),
            password: $request->validated('password'),
            avatarPath: $request->file('avatar')?->store('avatars'),
            timezone: $request->validated('timezone', 'UTC'),
        );
    }
}

// Action receives DTO
class CreateUser
{
    public function execute(CreateUserData $data): User
    {
        return User::create([
            'name' => $data->name,
            'email' => $data->email,
            'password' => Hash::make($data->password),
            'avatar_path' => $data->avatarPath,
            'timezone' => $data->timezone,
        ]);
    }
}

// Controller
public function store(RegisterRequest $request, CreateUser $createUser)
{
    $user = $createUser->execute(CreateUserData::fromRequest($request));
    return redirect('/dashboard');
}

When to use DTOs: Action has > 4 parameters, or parameters form a logical group (all user data).

Action That Returns a DTO

class CalculateOrderTotal
{
    public function execute(Order $order): OrderSummary
    {
        $subtotal = $order->items->sum(fn ($item) => $item->price * $item->quantity);
        $tax = $subtotal * $order->taxRate();
        $discount = $this->calculateDiscount($order);

        return new OrderSummary(
            subtotal: $subtotal,
            tax: $tax,
            discount: $discount,
            total: $subtotal + $tax - $discount,
        );
    }

    private function calculateDiscount(Order $order): int
    {
        if (!$order->coupon) {
            return 0;
        }

        return match ($order->coupon->type) {
            'percentage' => (int) ($order->subtotal * $order->coupon->value / 100),
            'fixed' => $order->coupon->value,
            default => 0,
        };
    }
}

Invokable Actions

Instead of execute(), use __invoke() for cleaner syntax:

class PublishPost
{
    public function __invoke(Post $post): Post
    {
        if ($post->isPublished()) {
            throw new PostAlreadyPublishedException($post);
        }

        $post->update([
            'published_at' => now(),
            'status' => PostStatus::Published,
        ]);

        event(new PostPublished($post));

        return $post;
    }
}

// Usage
$publishPost = app(PublishPost::class);
$publishPost($post); // Call like a function

Actions with Database Transactions

When an action needs atomicity:

class TransferMoney
{
    public function execute(Account $from, Account $to, int $amountInCents, string $description): Transfer
    {
        return DB::transaction(function () use ($from, $to, $amountInCents, $description) {
            if ($from->balance_in_cents < $amountInCents) {
                throw new InsufficientFundsException($from, $amountInCents);
            }

            $from->decrement('balance_in_cents', $amountInCents);
            $to->increment('balance_in_cents', $amountInCents);

            return Transfer::create([
                'from_account_id' => $from->id,
                'to_account_id' => $to->id,
                'amount_in_cents' => $amountInCents,
                'description' => $description,
            ]);
        });
    }
}

Tip: Dispatch events/notifications after the transaction commits to avoid sending notifications for rolled-back transactions:

$transfer = DB::transaction(function () { ... });

// Dispatch AFTER successful commit
event(new MoneyTransferred($transfer));

Organizing Actions

Directory Structure

app/Actions/
├── Auth/
│   ├── CreateUser.php
│   ├── ResetPassword.php
│   └── VerifyEmail.php
├── Posts/
│   ├── CreatePost.php
│   ├── PublishPost.php
│   ├── UnpublishPost.php
│   └── DeletePost.php
├── Orders/
│   ├── CreateOrder.php
│   ├── CalculateOrderTotal.php
│   ├── ProcessPayment.php
│   └── RefundOrder.php
└── Notifications/
    ├── SendWelcomeEmail.php
    └── SendOrderConfirmation.php

Naming Convention

Verb + Noun
──────────
CreateUser
UpdateProfile
DeletePost
PublishArticle
SendInvoice
CalculateDiscount
ImportCsvData
ExportReport

Always use a verb. The class name should describe what it does.

Testing Actions

Actions are trivially testable — no HTTP layer, no middleware, just input/output:

class CreateUserTest extends TestCase
{
    public function test_creates_user_with_hashed_password(): void
    {
        $action = new CreateUser();

        $user = $action->execute(
            name: 'John Doe',
            email: 'john@example.com',
            password: 'secret123',
        );

        $this->assertDatabaseHas('users', [
            'name' => 'John Doe',
            'email' => 'john@example.com',
        ]);

        $this->assertTrue(Hash::check('secret123', $user->password));
    }
}
class PublishPostTest extends TestCase
{
    public function test_publishes_post_and_clears_cache(): void
    {
        $post = Post::factory()->unpublished()->create();
        Cache::put("post_{$post->slug}", 'cached', 3600);

        $indexNow = $this->mock(IndexNowService::class);
        $indexNow->shouldReceive('submit')->once();

        $action = new PublishPost($indexNow);
        $result = $action->execute($post);

        $this->assertTrue($result->is_published);
        $this->assertNotNull($result->published_at);
        $this->assertNull(Cache::get("post_{$post->slug}"));
    }
}

Actions vs Services vs Jobs

Action Service Job
Purpose One operation Group of related operations Background task
Execution Synchronous Synchronous Async (queue)
Testability Excellent Moderate Good
Reusability High Medium Low
Complexity Simple Can grow Medium

Use Actions when: the operation is called from multiple places (controller, command, other actions).

Use Jobs when: the work should happen in the background.

Use Services when: you have infrastructure logic (API clients, file parsers) that isn't a business operation.

Composing Actions for Complex Workflows

class ProcessCheckout
{
    public function __construct(
        private ValidateInventory $validateInventory,
        private CalculateOrderTotal $calculateTotal,
        private ProcessPayment $processPayment,
        private CreateOrder $createOrder,
        private SendOrderConfirmation $sendConfirmation,
    ) {}

    public function execute(Cart $cart, PaymentMethod $payment): Order
    {
        // Step 1: Check stock
        $this->validateInventory->execute($cart->items);

        // Step 2: Calculate total
        $summary = $this->calculateTotal->execute($cart);

        // Step 3: Charge payment
        $charge = $this->processPayment->execute($payment, $summary->total);

        // Step 4: Create order record
        $order = $this->createOrder->execute($cart, $summary, $charge);

        // Step 5: Notify customer
        $this->sendConfirmation->execute($order);

        return $order;
    }
}

Each step is testable in isolation. The workflow is readable top-to-bottom. Adding a step means adding one line.

Migration: From Services to Actions

You don't need to refactor everything at once. Strategy:

1. Which service method is used the most? → Extract to Action first
2. Which is most complex? → Extract for better testability
3. New feature? → Write an Action directly, don't create a Service
4. Gradually the Service will have 1-2 methods left → Remove Service, replace with Actions

Actions and Jobs complement each other:

// Action for logic
class GenerateReport
{
    public function execute(ReportRequest $request): Report { ... }
}

// Job for async scheduling
class GenerateReportJob implements ShouldQueue
{
    public function handle(GenerateReport $generateReport): void
    {
        $report = $generateReport->execute($this->request);
        $this->user->notify(new ReportReady($report));
    }
}

Conclusion

Actions are not a framework feature — they're a pattern. No package needed. No interfaces to implement. Just discipline:

Checklist:

  1. One class, one public method (execute() or __invoke())
  2. Clear Verb + Noun naming
  3. Explicit parameters — avoid array $data when possible
  4. Use DTOs when > 4 parameters
  5. Test directly, no HTTP layer
  6. Organize by domain
  7. Start by extracting your most-reused controller logic

Comments