Skip to content

Actions Pattern

Actions are single-purpose classes that encapsulate business logic. They provide a clean, testable, and reusable way to handle complex operations.

Why Actions?

Traditional Controller (Fat Controllers)

php
class PostController extends Controller
{
    public function store(Request $request)
    {
        // Validation
        $validated = $request->validate([/*...*/]);

        // Business logic mixed with controller concerns
        $slug = Str::slug($validated['title']);

        if (Post::where('slug', $slug)->exists()) {
            $slug .= '-' . Str::random(5);
        }

        $post = Post::create([
            'title' => $validated['title'],
            'slug' => $slug,
            'content' => $validated['content'],
            'workspace_id' => auth()->user()->workspace_id,
        ]);

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

        event(new PostCreated($post));

        Cache::tags(['posts'])->flush();

        return redirect()->route('posts.show', $post);
    }
}

Problems:

  • Business logic tied to HTTP layer
  • Hard to reuse from console, jobs, or tests
  • Difficult to test in isolation
  • Controller responsibilities bloat

Actions Pattern (Clean Separation)

php
class PostController extends Controller
{
    public function store(StorePostRequest $request)
    {
        $post = CreatePost::run($request->validated());

        return redirect()->route('posts.show', $post);
    }
}

class CreatePost
{
    use Action;

    public function handle(array $data): Post
    {
        $slug = $this->generateUniqueSlug($data['title']);

        $post = Post::create([
            'title' => $data['title'],
            'slug' => $slug,
            'content' => $data['content'],
        ]);

        if (isset($data['tags'])) {
            $post->tags()->sync($data['tags']);
        }

        event(new PostCreated($post));
        Cache::tags(['posts'])->flush();

        return $post;
    }

    private function generateUniqueSlug(string $title): string
    {
        $slug = Str::slug($title);

        if (Post::where('slug', $slug)->exists()) {
            $slug .= '-' . Str::random(5);
        }

        return $slug;
    }
}

Benefits:

  • Business logic isolated from HTTP concerns
  • Reusable from anywhere (controllers, jobs, commands, tests)
  • Easy to test
  • Single responsibility
  • Dependency injection support

Creating Actions

Basic Action

php
<?php

namespace Mod\Blog\Actions;

use Core\Actions\Action;
use Mod\Blog\Models\Post;

class PublishPost
{
    use Action;

    public function handle(Post $post): Post
    {
        $post->update([
            'published_at' => now(),
            'status' => 'published',
        ]);

        return $post;
    }
}

Using Actions

php
// Static call (recommended)
$post = PublishPost::run($post);

// Instance call
$action = new PublishPost();
$post = $action->handle($post);

// Via container (with DI)
$post = app(PublishPost::class)->handle($post);

Dependency Injection

Actions support constructor dependency injection:

php
<?php

namespace Mod\Blog\Actions;

use Core\Actions\Action;
use Mod\Blog\Models\Post;
use Mod\Blog\Repositories\PostRepository;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\Cache\Repository as Cache;

class CreatePost
{
    use Action;

    public function __construct(
        private PostRepository $posts,
        private Dispatcher $events,
        private Cache $cache,
    ) {}

    public function handle(array $data): Post
    {
        $post = $this->posts->create($data);

        $this->events->dispatch(new PostCreated($post));
        $this->cache->tags(['posts'])->flush();

        return $post;
    }
}

Action Return Types

Returning Models

php
class CreatePost
{
    use Action;

    public function handle(array $data): Post
    {
        return Post::create($data);
    }
}

$post = CreatePost::run($data);

Returning Collections

php
class GetRecentPosts
{
    use Action;

    public function handle(int $limit = 10): Collection
    {
        return Post::published()
            ->latest('published_at')
            ->limit($limit)
            ->get();
    }
}

$posts = GetRecentPosts::run(5);

Returning Boolean

php
class DeletePost
{
    use Action;

    public function handle(Post $post): bool
    {
        return $post->delete();
    }
}

$deleted = DeletePost::run($post);

Returning DTOs

php
class AnalyzePost
{
    use Action;

    public function handle(Post $post): PostAnalytics
    {
        return new PostAnalytics(
            views: $post->views()->count(),
            averageReadTime: $this->calculateReadTime($post),
            engagement: $this->calculateEngagement($post),
        );
    }
}

$analytics = AnalyzePost::run($post);
echo $analytics->views;

Complex Actions

Multi-Step Actions

php
class ImportPostsFromWordPress
{
    use Action;

    public function __construct(
        private WordPressClient $client,
        private CreatePost $createPost,
        private AttachCategories $attachCategories,
        private ImportMedia $importMedia,
    ) {}

    public function handle(string $siteUrl, array $options = []): ImportResult
    {
        $posts = $this->client->fetchPosts($siteUrl);
        $imported = [];
        $errors = [];

        foreach ($posts as $wpPost) {
            try {
                DB::transaction(function () use ($wpPost, &$imported) {
                    // Create post
                    $post = $this->createPost->handle([
                        'title' => $wpPost['title'],
                        'content' => $wpPost['content'],
                        'published_at' => $wpPost['date'],
                    ]);

                    // Import media
                    if ($wpPost['featured_image']) {
                        $this->importMedia->handle($post, $wpPost['featured_image']);
                    }

                    // Attach categories
                    $this->attachCategories->handle($post, $wpPost['categories']);

                    $imported[] = $post;
                });
            } catch (\Exception $e) {
                $errors[] = [
                    'post' => $wpPost['title'],
                    'error' => $e->getMessage(),
                ];
            }
        }

        return new ImportResult(
            imported: collect($imported),
            errors: collect($errors),
        );
    }
}

Actions with Validation

php
class UpdatePost
{
    use Action;

    public function __construct(
        private ValidatePostData $validator,
    ) {}

    public function handle(Post $post, array $data): Post
    {
        // Validate before processing
        $validated = $this->validator->handle($data);

        $post->update($validated);

        return $post->fresh();
    }
}

class ValidatePostData
{
    use Action;

    public function handle(array $data): array
    {
        return validator($data, [
            'title' => 'required|max:255',
            'content' => 'required',
            'published_at' => 'nullable|date',
        ])->validate();
    }
}

Action Patterns

Command Pattern

Actions are essentially the Command pattern:

php
interface ActionInterface
{
    public function handle(...$params);
}

// Each action is a command
class PublishPost implements ActionInterface { }
class UnpublishPost implements ActionInterface { }
class SchedulePost implements ActionInterface { }

Pipeline Pattern

Chain multiple actions:

php
class ProcessNewPost
{
    use Action;

    public function handle(array $data): Post
    {
        return Pipeline::send($data)
            ->through([
                ValidatePostData::class,
                SanitizeContent::class,
                CreatePost::class,
                GenerateExcerpt::class,
                GenerateSocialImages::class,
                NotifySubscribers::class,
            ])
            ->thenReturn();
    }
}

Strategy Pattern

Different strategies as actions:

php
interface PublishStrategy
{
    public function publish(Post $post): void;
}

class PublishImmediately implements PublishStrategy
{
    public function publish(Post $post): void
    {
        $post->update(['published_at' => now()]);
    }
}

class ScheduleForLater implements PublishStrategy
{
    public function publish(Post $post): void
    {
        PublishPostJob::dispatch($post)
            ->delay($post->scheduled_at);
    }
}

class PublishPost
{
    use Action;

    public function handle(Post $post, PublishStrategy $strategy): void
    {
        $strategy->publish($post);
    }
}

Testing Actions

Unit Testing

Test actions in isolation:

php
<?php

namespace Tests\Unit\Mod\Blog\Actions;

use Tests\TestCase;
use Mod\Blog\Actions\CreatePost;
use Mod\Blog\Models\Post;

class CreatePostTest extends TestCase
{
    public function test_creates_post_with_valid_data(): void
    {
        $data = [
            'title' => 'Test Post',
            'content' => 'Test content',
        ];

        $post = CreatePost::run($data);

        $this->assertInstanceOf(Post::class, $post);
        $this->assertEquals('Test Post', $post->title);
        $this->assertDatabaseHas('posts', [
            'title' => 'Test Post',
        ]);
    }

    public function test_generates_unique_slug(): void
    {
        Post::factory()->create(['slug' => 'test-post']);

        $post = CreatePost::run([
            'title' => 'Test Post',
            'content' => 'Content',
        ]);

        $this->assertNotEquals('test-post', $post->slug);
        $this->assertStringStartsWith('test-post-', $post->slug);
    }
}

Mocking Dependencies

php
public function test_dispatches_event_after_creation(): void
{
    Event::fake();

    $post = CreatePost::run([
        'title' => 'Test Post',
        'content' => 'Content',
    ]);

    Event::assertDispatched(PostCreated::class, function ($event) use ($post) {
        return $event->post->id === $post->id;
    });
}

Integration Testing

php
public function test_import_creates_posts_from_wordpress(): void
{
    Http::fake([
        'wordpress.example.com/*' => Http::response([
            [
                'title' => 'WP Post 1',
                'content' => 'Content 1',
                'date' => '2026-01-01',
            ],
            [
                'title' => 'WP Post 2',
                'content' => 'Content 2',
                'date' => '2026-01-02',
            ],
        ]),
    ]);

    $result = ImportPostsFromWordPress::run('wordpress.example.com');

    $this->assertCount(2, $result->imported);
    $this->assertCount(0, $result->errors);
    $this->assertEquals(2, Post::count());
}

Action Composition

Composing Actions

Build complex operations from simple actions:

php
class PublishBlogPost
{
    use Action;

    public function __construct(
        private UpdatePost $updatePost,
        private GenerateOgImage $generateImage,
        private NotifySubscribers $notifySubscribers,
        private PingSearchEngines $pingSearchEngines,
    ) {}

    public function handle(Post $post): Post
    {
        // Update post status
        $post = $this->updatePost->handle($post, [
            'status' => 'published',
            'published_at' => now(),
        ]);

        // Generate social images
        $this->generateImage->handle($post);

        // Notify subscribers
        dispatch(fn () => $this->notifySubscribers->handle($post))
            ->afterResponse();

        // Ping search engines
        dispatch(fn () => $this->pingSearchEngines->handle($post))
            ->afterResponse();

        return $post;
    }
}

Conditional Execution

php
class ProcessPost
{
    use Action;

    public function handle(Post $post, array $options = []): Post
    {
        if ($options['publish'] ?? false) {
            PublishPost::run($post);
        }

        if ($options['notify'] ?? false) {
            NotifySubscribers::run($post);
        }

        if ($options['generate_images'] ?? true) {
            GenerateSocialImages::run($post);
        }

        return $post;
    }
}

Best Practices

1. Single Responsibility

Each action should do one thing:

php
// ✅ Good - focused actions
class CreatePost { }
class PublishPost { }
class NotifySubscribers { }

// ❌ Bad - does too much
class CreateAndPublishPostAndNotifySubscribers { }

2. Meaningful Names

Use descriptive verb-noun names:

php
// ✅ Good names
class CreatePost { }
class UpdatePost { }
class DeletePost { }
class PublishPost { }
class UnpublishPost { }

// ❌ Bad names
class PostAction { }
class HandlePost { }
class DoStuff { }

3. Return Values

Always return something useful:

php
// ✅ Good - returns created model
public function handle(array $data): Post
{
    return Post::create($data);
}

// ❌ Bad - returns nothing
public function handle(array $data): void
{
    Post::create($data);
}

4. Idempotency

Make actions idempotent when possible:

php
class PublishPost
{
    use Action;

    public function handle(Post $post): Post
    {
        // Idempotent - safe to call multiple times
        if ($post->isPublished()) {
            return $post;
        }

        $post->update(['published_at' => now()]);

        return $post;
    }
}

5. Type Hints

Always use type hints:

php
// ✅ Good - clear types
public function handle(Post $post, array $data): Post

// ❌ Bad - no types
public function handle($post, $data)

Common Use Cases

CRUD Operations

php
class CreatePost { }
class UpdatePost { }
class DeletePost { }
class RestorePost { }

State Transitions

php
class PublishPost { }
class UnpublishPost { }
class ArchivePost { }
class SchedulePost { }

Data Processing

php
class ImportPosts { }
class ExportPosts { }
class SyncPosts { }
class MigratePosts { }

Calculations

php
class CalculatePostStatistics { }
class GeneratePostSummary { }
class AnalyzePostPerformance { }

External Integrations

php
class SyncToWordPress { }
class PublishToMedium { }
class ShareOnSocial { }

Action vs Service

When to Use Actions

  • Single, focused operations
  • No state management needed
  • Reusable across contexts

When to Use Services

  • Multiple related operations
  • Stateful operations
  • Facade for complex subsystem
php
// Action - single operation
class CreatePost
{
    use Action;

    public function handle(array $data): Post
    {
        return Post::create($data);
    }
}

// Service - multiple operations, state
class BlogService
{
    private Collection $posts;

    public function getRecentPosts(int $limit): Collection
    {
        return $this->posts ??= Post::latest()->limit($limit)->get();
    }

    public function getPopularPosts(int $limit): Collection { }
    public function searchPosts(string $query): Collection { }
    public function getPostsByCategory(Category $category): Collection { }
}

Learn More

Released under the EUPL-1.2 License.