Isak Berglind

Isak Berglind

It's all about the Action

Isak Berglind • September 2, 2025

php laravel action

Lately I have seen actions popping up everywhere. People talk about them in streams and on social media, conference speakers use them, and I see them in other people's code.

I love actions! I started using them on a project back in 2021 and instantly liked how clear the code became. Navigating the codebase and finding exactly what I needed suddenly felt effortless.

As projects grow, business logic often ends up scattered across controllers, jobs, and services. Actions give that logic a clear home, making it easier to find, reuse, and test.

This post assumes that you are pretty familiar with Laravel and php, and will not explain Laravel concepts such as Controllers, Jobs, Commands, Notifications and so on. If these are foreign to you - it might help to read a bit of the Laravel documentation for this to make more sense.

What Is an Action?

So what is an action, and why should you care?

The way I think about it, an action contains a small and specific task your app needs to do. It can be the creation of a user, getting some piece of data or calculating some value. It's your business logic encapsulated in small, reusable classes.

If we continue with the creation of a user example, an action for that might look like this:

class CreateUserAction
{
    public function handle(string $email, string $name, string $password)
    {
        $user = User::create([
            'name' => $name,
            'email' => $email,
            'password' => $password,
        ]);

        $user->notify(new SendWelcomeNotification());

        event(new UserCreated($user->id));

        return $user;
    }
}

Please bear with me in this very basic example that might not make sense in a real scenario.

Now we can use this action for example in a controller:

class CreateUserController extends Controller
{
    public function __construct(
        private CreateUserAction $createUserAction
    ) {}

    public function __invoke(CreateUserFormRequest $createUser)
    {
        $user = $this->createUserAction->handle(
            $createUser->email,
            $createUser->name,
            bcrypt($createUser->password),
        );

        return response()->json($user, 201);
    }
}

Benefits

Actions bring several immediate wins:

Reusable

We can have several entry points to our application. We have the controller like above, but we might also want to create users in a command, or in a listener or a queued job. We can use the action above for all of those cases.

Easy to Test

Since the logic lives in the action instead of the controller, you can write focused tests that really verify the behavior. Meanwhile, your controller can focus on orchestrating tasks and formatting responses, without worrying about the “how.”

it('creates a user successfully', function () {
    $user = (new CreateUserAction())->handle(
        '[email protected]', 
        'Test User', 
        'password'
    );

    expect($user)->toBeInstanceOf(User::class);
    expect($user->exists)->toBeTrue();
    expect($user->email)->toBe('[email protected]');
    expect($user->name)->toBe('Test User');
    expect(Hash::check('password', $user->password))->toBeTrue();
});

Easy to Find

Need to check or modify how a certain task works? If most of your business logic is in actions, you already know where to look. A simple search usually takes you straight to the right class, saving you time and frustration.

Guidelines

What I love about this pattern, is that it's simple and easy to understand. I do not want to make this more complicated just for the sake of it, but I've found certain tips & tricks that have made actions more versatile and useful. So if you're new to actions, maybe stop reading here and try them out yourself. Try to get a feel for it and see if it vibes with your thinking. Otherwise, please continue further down as I describe the guidelines I'm using while creating actions.

Naming

I like the [Verb][Object][Qualifier?]Action format. Without the optional qualifier it can look like this:

CreateOrderAction, DeleteOrderRowAction, CalculateOrderVATAction, GetInactiveUsersAction.

Sometimes you will need the qualifier, like in:

CreateOrderFromCartAction, MarkSubmissionAsDraftAction, GetOrdersForUserAction.

I try to stick to the same verbs as much as possible. Some verbs mean pretty much the same thing. Words like "get" and "find", "check" and "validate", "has" and "is", "handle" and "process" and so on.

Only One Entry

The action should have only one public method. Call it handle, call it execute - it doesn't matter. What matters is that this should not be a big interface at all. One method to rule them all! This ensures actions are predictable and have a single, clear purpose.

Inputs and Outputs

Use basic inputs and outputs. Try only to use primitives or DTO's. A DTO, or Data Transfer Object, is a simple class used to pass structured data between layers.
If you use DTO's, prefer immutable ones. For pragmatic reasons, I also think using eloquent models and collections are okay.

Maybe you saw my first example above, and wondered why I didn't just pass the form object straight into the action? This is why; by passing the form object, we're basically coupling the action to only be used in controllers. While you could absolutely create a form request in a listener or a command, that's pretty awkward.

The same goes for the return values. We could have made the controller super clean by returning the http response directly from the action, but again, now it's concerned with where it is used, which in my mind is not a concern for the action.

We should also expect the inputs to already be valid. Validation should take place before the action is called.

Focus on the Business Logic

As we want to put all business logic in actions, make the action focused on exactly that. What I mean by that is don't bother with catching exceptions, caching values or similar. Let that be handled by the consumer of the action. This way the action will be more readable, testable and reusable.

Do One Thing

People like to debate the Single Responsibility Principle. I have mixed feelings in general, but when it comes to actions, I think we can take it pretty far. Do one thing, and do it well.

Let's say we're writing a command. A common scenario is that we first get some data, and then manipulate that data somehow. Say we want to get all users that have not logged in for some time, and inactivate them. Let's take a first stab at this:

// console.php
Artisan::command('inactivate-users', function () {
    (new InactivateUsersAction)->handle();
});

// InactivateUsersAction.php
class InactivateUsersAction
{
    public function handle()
    {
        $users = User::where('last_logged_in_at', '<', now()->subMonths(6))
            ->get();

        foreach ($users as $user) {
            $user->update([
                'is_active' => 0
            ]);

            $user->notify(new UserInactivatedNotification());
        }

        return $users;
    }
}

This is fine. However, a common pattern I usually do is splitting this into two actions. One action that gets the data, and one that manipulates it. Like this:


// console.php
Artisan::command('inactivate-users', function () {
    $users = (new GetUsersToInactivateAction)->handle();

    foreach ($users as $user) {
        (new InactivateUserAction)->handle($user);
    }
});

// GetUsersToInactivateAction.php
class GetUsersToInactivateAction
{
    public function handle(): Collection
    {
        return User::where('last_logged_in_at', '<', now()->subMonths(6))
            ->get();
    }
}

// InactivateUserAction.php
class InactivateUserAction
{
    public function handle(User $user): bool
    {
        $user->update([
            'is_active' => 0
        ]);

        $user->notify(new UserInactivatedNotification());

        return true;
    }
}

This has several advantages. First - see how incredibly readable the command is? By focusing on the "what" and not on the "how", this commands reads like English. We do not know by looking at that code what makes a user inactive, but at this level, we do not care. If we would like to know what this actually implies, we have an easy way of checking it - just open the action.

What's more - we have made the actions even more simple, testable and reusable.

It's more reusable, as we might need to inactivate users in other parts of the app, just not when they haven't been active.

It's more testable, because now the tests can be laser-focused.

In the "getter" action test, we can make sure to test every single where statement and scope, to make sure we only get the data we need.

it("gets users that haven't logged in for six months", function () {
    $users = User::factory()
        ->count(2)
        ->create(["last_logged_in_at" => now()->subMonths(6)]);

    $foundUsers = (new GetUsersToInactivateAction)->handle();

    expect($foundUsers->pluck('id'))
        ->toHaveCount(2)
        ->toContain(...$users->pluck('id'));
});

// more tests...

In the "manipulator" action, on the other hand, we are no longer required to send in a user that meets that criteria anymore. We can simply focus on the making any user inactive.

it("inactivates a user", function () {
    $user = User::factory()->create(['is_active' => 1])

    $result = (new InactivateUserAction)->handle($user);

    expect($result)->toBeTrue();
    expect($user->fresh())
        ->is_active
        ->toBe(0);
});

// more tests..

This example is, of course, pretty basic. In a real application, there would probably be some more rules and business logic in both of the actions.

Where to Put Them

I usually keep actions in an app/Actions folder in Laravel.

If the folder starts getting crowded, it helps to group them by domain or feature area, for example:

app/Actions/Users app/Actions/Orders app/Actions/Products

This makes it easy to navigate as your project grows, you immediately know where to look for anything related to a specific part of the application.

There’s no strict rule here, the goal is to keep things predictable and easy to find.

When Not to Use Actions

While I tend to use actions in most projects - sometimes it might be overkill. In some apps, adding a new concept like this might bring more complexity than it brings clarity. In those cases just don't use them.

Not all business logic needs to go into an action. It’s fine to keep some in a controller if it makes sense. The most important thing is that you and your team are productive and use patterns that you think helps you.

I find myself striving against using actions for almost all business logic. That's my preferred way. But at the end of the day, actions are a tool. They should make your codebase easier to navigate and reason about. If they don’t, it’s okay to skip them. The goal isn’t 100% purity - it’s productivity and clarity for your team.

Introducing Flows

In this section we will dig a bit deeper, and maybe solve some problems you might not have. If that's the case - don't do it. All of this is meant to make your code simpler and easier to work with, not add abstractions for the sake of it and coming up with solutions that's overkill. With that said, let's go!

Say we have created a bunch of different actions, and we use them in our controllers, listeners or commands. Now you might have stumbled into another issue. We might see code repeated over and over.

If we continue with our example above, and we want inactivate users both in a command and a listener - chances are that the code will be duplicated, which kind of defeats the purpose of using actions to begin with.

What I have done in some code bases, is introducing a second layer of actions. These actions act as an orchestration, and are responsible for the plumbing of a certain process or a flow. They shouldn't contain any real business logic - that lives better inside the individual actions.

These flows share some of the same "rules" that I've set for the actions - they should accept and return primitives or DTO's, but these actions should only be responsible for delegating to other actions, handling errors, retries and so on.

Let's extract a new flow from the command above.

class InactivateDormantUsersFlow
{
    public function handle()
    {
        $users = (new GetUsersToInactivateAction)->handle();

        foreach ($users as $user) {
            (new InactivateUserAction)->handle($user);
        }
    }
}

This flow can now be used from anywhere. It's readable, and we no longer duplicate code across controllers, listeners and commands.

For flows, you can follow the same structure under app/Flows, grouping them by domain if it makes sense for your app.

Flows vs Services vs Jobs

You might look at the example above and wonder why this isn't a Job or a Service. It could totally be a job, service or something similar. If you don't see any benefit to this extra layer of abstraction - don't use it. Simplicity is king. Only add complexity if it solves a real issue.

I use flows a lot in an app that implements a domain driven structure, in where each domain has a domain service as the interface to the app. The flows are then useful, as I do not want to call the domain service from within the service itself. By extracting it to a flow, I can use it from anywhere within that domain in a way that to me feels more natural.

Using jobs is fine - but keep in mind that jobs lack the ability to return data. If I need to do the flow async, I would rather make a job that calls the flow.

Think of flows as orchestrators: they coordinate actions but don’t contain business rules themselves.

More Complex Flows

Let's make the example above slightly more complicated. Let's say that the InactivateUserAction will throw an exception if you try to inactivate an admin user, as may not want to lock out our admins.

class InactivateUserAction
{
    public function handle(User $user): bool
    {
        if ($user->isAdmin()) {
            throw new AdminCannotBeInactivatedException();
        }

        $user->update([
            'is_active' => 0
        ]);

        $user->notify(new UserInactivatedNotification());

        return true;
    }
}

If we follow the rules we set up for ourselves, we shouldn't handle the exception in the action. Instead, we delegate that to the caller of the action - in this case - our flow.

class InactivateDormantUsersFlow
{
    public function handle()
    {
        $users = (new GetUsersToInactivateAction)->handle();

        foreach ($users as $user) {
            try {
                (new InactivateUserAction)->handle($user);
            } catch (AdminCannotBeInactivatedException $e) {
                Log::warning("Trying to inactivate an admin user. Skipping.", [
                    "user" => $user->id
                ]);
            }
        }
    }
}

Testing Actions vs Flows

We know how to test the actions, it's pretty straight forward. But how do we test flows?

The way I found most useful is to test the happy path and make sure any branches and try/catches work.

This means these tests require more setup than testing individual actions, but they don't need to be as thorough.

Going back to our example, the test for the flow might look something like this:

it("inactivates dormant users", function () {
    $activeUser = User::factory()->create([
        "is_active" => 1,
    ]);
    [$dormantUserA, $dormantUserB] = User::factory()->count(2)->create([
        "is_active" => 1,
        "last_logged_in_at" => now()->subMonths(6)
    ]);

    (new InactivateDormantUsersFlow)->handle();

    expect($activeUser->fresh())
        ->is_active
        ->toBe(1);
    expect($dormantUserA->fresh())
        ->is_active
        ->toBe(0);
    expect($dormantUserB->fresh())
        ->is_active
        ->toBe(0);
});

it("skips admin users", function () {
    $dormantAdminUser = User::factory()->create([
        "is_active" => 1,
        "last_logged_in_at" => now()->subMonths(6)
    ]);

    (new InactivateDormantUsersFlow)->handle();

    expect($dormantAdminUser->fresh())
        ->is_active
        ->toBe(1);
});

Tools & Mocking

Something I like to do is adding a factory method.

I have gone for the SomeAction::make()->handle() pattern. But choose whatever makes sense to you. SomeAction::execute() is also great. Whatever floats your boat.

By resolving the class from the container, we can also mock it more easily.

public static function make()
{
    return app(static::class);
}

Now we can use it like this:

SomeAction::make()->handle();

Mocking

Mocking is a heated subject. Some people love it, some avoid it entirely, and others are careful about when to use it

Mocking allows you to isolate parts of your code in tests, replacing dependencies with controllable fakes. This is especially useful for actions with side effects, like HTTP calls, events, or notifications.

I think mocking has its place, and can be a really useful tool.

When To Mock

While testing actions, I try not to mock, with the exceptions of HTTP calls. While testing flows, I try not to mock if it's straightforward, but if there are actions that have a bunch of side effects like events, HTTP calls and similar - sometimes I do mock.

How To Mock

To be able to easily mock, I've added a fake() method to my action base class:

public static function fake(?string $alias = null): MockInterface | LegacyMockInterface
{
    $mock = Mockery::mock(static::class);
    app()->offsetSet($alias ?? static::class, $mock);

    return $mock;
}

This mocks the action, but also puts the mock in the container, if the action is resolved. By passing a string, you can also supply an alias.

Now we can either let Laravel dependency inject our actions & flows, or we can use the make() function. Both will resolve the mock if we want to mock it.


class SomeAction
{
    public function __construct(
        private SomeOtherAction $someOtherAction
    } {}

    public function handle()
    {
        // both versions below will use an instance resolved from the container

        $this->someOtherAction->handle();

        SomeOtherAction::make()->handle();
    }
}

Here's an example of mocking an action in a flow:

it("inactivates dormant users", function () {
    $dormantUser = User::factory()
        ->create([
            "is_active" => 1,
            "last_logged_in_at" => now()->subMonths(6)
        ]);

    InactivateUserAction::fake()
        ->shouldReceive('handle')
        ->once()
        ->withArgs(function ($user) use ($dormantUser) {
            $this->assertTrue($user->is($dormantUser));

            return true;
        });

    (new InactivateDormantUsersFlow)->handle();
});

Should You Use a Package?

There’s a popular package called Laravel Actions that approaches this problem in a slightly different way. It’s well-maintained, widely used, and the maintainers are clearly doing a fantastic job.

That said, I haven’t used it myself, mainly because I prefer to keep my actions as simple as possible. With the approach outlined in this post, we can go a long way, even without a base action class.

The Laravel Actions package takes on a bit more responsibility by allowing the same class to act as multiple different Laravel components. While that can be convenient, it also means that if the package ever stops being maintained, I could be looking at a significant refactor. For something I can easily handle with a few lines of code myself, I tend to pass on the extra dependency.

This isn’t a criticism of the package, it’s just my personal preference. If you like what it offers, it might be a great fit for your project.

Conclusion

Actions have been a huge win for me. They make business logic easy to find, easy to test, and easy to reuse across different parts of an application. On top of that, they keep controllers, listeners, and commands clean and focused on what they should do.

At the same time, actions aren’t magic. They won’t solve every problem, and you don’t need to use them everywhere. Think of them as one more tool in your toolbox - a simple pattern you can reach for when you want more structure and clarity.

If you’re new to the idea, start small. Pick one piece of business logic in your app, move it into an action, and see how it feels. If it clicks for you and your team, build on it. If not, that’s fine too. The most important thing is writing code that’s clear, maintainable, and works for your context.

Bonus: Be Aware of Progress

if you just need simple actions, ignore this part for now. Only use this when you need fine-grained progress reporting or logging.

As a bonus, i want to address something that I've had some head aches with. It is that we might want to know from the outside (controller, listener, command) how it's going with the flow. Let's say we run this as a command.

Artisan::command('inactivate-users', function () {
    InactivateDormantUsersFlow::make()->handle();
});

If I run this command. I want some output. I want to know how many users were affected, if any failed and so on. Right now we are totally blind to the results. We can of course log everything, and read the log files, but that feels like bad DX. I want nice output right in the terminal!

We could also pass an instance to some logging/output interface, and use that to output everything we like to know. Something like this:

class InactivateDormantUsersFlow
{
    public function handle(OutputInterface $output)
    {
        $users = GetUsersToInactivateAction::make()->handle();

        $output->write("Found {$users->count()} users");

        foreach ($users as $user) {
            try {
                InactivateUserAction::make()->handle($user);

                $output->write("Successfully inactivated {$user->id} ");
            } catch (AdminCannotBeInactivatedException $e) {
                $output->write("Failed to inactivate {$user->id}");
            }
        }
    }
}

This is fine, but has some drawbacks. We still do not have any control in our command. We might want more information about the found users, and care less about the successful users for example. Now we are stuck with what we have, and changing it will also change every other place where this flow is ran.

Eventing to the Rescue

What I would really like to do is to use some kind of pub/sub model, to listen to what we care about. Then, in whatever context we are in, we can log/output what is relevant to us, in whatever way or format that suits best.

This will need a bit more code. Let's now take the step and create a Action base class.

abstract class Action
{
    /**
     * Listen for an event emitted by this action.
     */
    public function on(string $event, callable $callback): static
    {        
        Event::listen($this->generateEventName($event), $callback);

        return $this;
    }

    /**
     * Emit an event from this action.
     */
    protected function event(string $event, mixed $data): static
    {
        event($this->generateEventName($event), $data);

        return $this;
    }

    /**
     * Generate a unique string for this class instance.
     */
    protected function generateEventName(string $event): string
    {
        return static::class . '.' . spl_object_hash($this) . '.' . $event;
    }
}

We now take advantage of Laravel event system to be able to listen and fire events. We can use proper event classes, but I find that to be overkill in this situation, so instead we can use string events. To avoid name conflicts, and the risk of getting outputs from all instances if you have several at the same time. We use spl_object_hash to generate a unique identifier for each action instance, ensuring events don’t conflict if multiple actions run simultaneously. Let's update our implementation:

class InactivateDormantUsersFlow extends Action
{
    public function handle(OutputInterface $output)
    {
        $users = GetUsersToInactivateAction::make()->handle();

        $this->event("data_received", $users);

        foreach ($users as $user) {
            try {
                InactivateUserAction::make()->handle($user);

                $this->event("item_successful", $user);
            } catch (AdminCannotBeInactivatedException $e) {
                $this->event("item_unsuccessful", $user);
            }
        }
    }
}

Now in the command - we can choose which event we listen to, and how to format the output

// Outputs: "2 users found" and "User 123 could not be inactivated"
Artisan::command('inactivate-users', function () {
    InactivateDormantUsersFlow::make()
        ->on("data_received", fn ($users) => 
            $this->info("{$users->count()} users found")
        )
        ->on('item_unsuccessful', fn ($user) =>
            $this->warn("User {$user->id} could not be inactivated")
        )
        ->handle();
});

This is, in my opinion, pretty neat. If we don't care about output, we just call the flow as is, without needing to pass anything logging related. The flow can provide all sorts of events, and the caller can just choose to listen to what matters.

A Friendly Warning

I would like to warn you about getting too creative with this.

Using events like this might give you the option to do really clever things. Clever things are not always simple things, however, and you might end up with the logic of the action leaking out, and being modified by other code. This sort of thing goes against the spirit of actions and can easily become a footgun if you’re not careful. With that said, this can be used pretty cleverly in tests, if you want to assert against something that is not returned or similar.

So my advice is to stick to the simple input/output for the business logic, only use events for logging and messages.

Further improving

While the eventing system above works - I would like to address the use of magic strings. While this is not a huge issue, it might cause problems due to typos or firing/listening to events that do not exist.

Adding Validation

To handle this, I've added some validation to the event names.

In the action/flow, I've added a new attribute


// EmitsEvents.php

#[Attribute(Attribute::TARGET_CLASS)]
class EmitsEvents
{
    public function __construct(public array $events)
    {
        foreach ($events as $event) {
            if (!is_string($event)) {
                throw new InvalidArgumentException('All events must be strings');
            }
        }
    }
}

// InactivateDormantUsersFlow.php

#[EmitsEvents(['data_received', 'item_manipulated'])]
class InactivateDormantUsersFlow
{
    //...
}

In the action base class, we can now add some validation. By checking if the event that's fired/listened to is actually present in the attribute, and throwing an exception if there is a mismatch, we can prevent typos and invalid event names.


    public function on(string $event, callable $callback): static
    {        
        $this->throwIfEventNotAllowed($event, "Cannot listen for event '{$event}'.");

        Event::listen($this->generateEventName($event), $callback);

        return $this;
    }

    public function event(string $event, mixed $data): static
    {
        $this->throwIfEventNotAllowed($event, "Cannot emit event '{$event}'.");

        event($this->generateEventName($event), $data);

        return $this;
    }

    protected function throwIfEventNotAllowed(string $event, string $description): void
    {
        $allowedEvents = $this->getAllowedEvents();

        if (in_array($event, $allowedEvents)) {
            return;
        }

        throw new InvalidArgumentException($description);
    }

    protected function getAllowedEvents(): array
    {
        $reflection = new ReflectionClass(static::class);
        $attributes = $reflection->getAttributes(EmitsEvents::class);

        if (empty($attributes)) {
            return [];
        }

        $emitsEventsAttribute = $attributes[0]->newInstance();
        return $emitsEventsAttribute->events;
    }

Helpful Exception Messages

To be extra helpful, we can guide the user to the right event name, if there is a simple typo. By using the levenshtein function. Levenshtein measures the difference between two strings, helping us detect likely typos. We can see if the provided event closely matches a legal event. If so, we can suggest it.


protected function throwIfEventNotAllowed(string $event, string $description): void
{
    $allowedEvents = $this->getAllowedEvents();

    if (in_array($event, $allowedEvents)) {
        return;
    }

    $closest = collect($allowedEvents)
        ->map(fn ($allowedEvent) => [
            'option' => $allowedEvent,
            'distance' => levenshtein($event, $allowedEvent),
        ])
        ->sortBy('distance')
        ->filter(fn ($event) => $event['distance'] <= 3)
        ->map(fn ($event) => $event['option'])
        ->first();

    $message = Str::of($description)
        ->when($closest, fn($str) => $str->append(" Did you mean: '{$closest}'?"))
        ->when(!$closest, fn($str) => $str->append(" Allowed events: " . implode(', ', $allowedEvents) . "."));

    throw new InvalidArgumentException((string) $message);
}

Pretty cool, right?