Laravel’s Action: A Simplified Guide to Cleaner Code and Testing

Dmytro
3 min readMar 28, 2024

--

Organizing code is essential in Laravel development. The Action feature facilitates this organization by encapsulating logic into reusable classes, eliminating the need for extra service classes. This article explores how Actions enhance code organization, promote reusability, and streamline development by removing the necessity for additional service layers. Understanding the benefits of Laravel’s Action empowers developers to make their projects more efficient and maintainable. Now, let’s dive into Laravel’s Action and uncover its potential for cleaner, more maintainable code.

Using Actions in Controllers

Here’s a demonstration of using an Action within a controller method:

final readonly class UserInvitationController
{
public function __invoke(User $user): JsonResponse
{
action(new SendInvitationEmail($user));

return ApiResponse::success();
}
}

Now, let’s take a closer look at an example of a reusable Action class:

Action Implementation

readonly class SendInvitationEmail
{
public function __construct(
private User $user,
) {
}

public function handle(UserRepository $userRepository): void
{
if ($this->user->invitationStatus->isAccepted()) {
throw new UserAlreadyAcceptedInvitation();
}

Mail::to($this->user)->send(new UserInvitationMail($this->user));

$userRepository->updateInvitationStatus($this->user->id, InvitationStatus::Invited);
}
}

The handle method serves as the entry point for executing the action. It accepts an UserRepository object as a parameter, which Laravel automatically resolves from the service container.

Testing the Action.

Testing the Action follows a similar pattern to testing jobs and events. Here’s an example of a test case:

it('can send an invitation to the user', function () {
Action::fake(SendInvitationEmail::class);

$user = User::factory()->create();

action(new SendInvitationEmail($user));

Action::assertDispatched(SendInvitationEmail::class);
});

In this example, we simulate sending an invitation email to a user and assert that the Action is dispatched correctly. Using the Action::fake() method, we isolate the email-sending process from external dependencies.

How it works

Let’s delve into the implementation details. We’ll begin by examining the ActionManager and Facade.

Here’s an Action Manager implementation:

class ActionManager
{
protected Application $app;

protected array $ranActions = [];

public function __construct(Application $app)
{
$this->app = $app;
}

public function dispatch(object $action): mixed
{
if (! method_exists($action, 'handle')) {
throw new RuntimeException("Action '$action' does not have a handle method.");
}

$this->ranActions[] = $action;

return $this->process($action);
}

public function getRanActions(): array
{
return $this->ranActions;
}

final protected function process(object $action): mixed
{
return $this->app->call([$action, 'handle']);
}
}

In this ActionManager class, the dispatch method is responsible for executing the specified action. It checks if the action has a handle method, adds the action to the list of executed actions, and then processes the action. The getRanActions method allows retrieving the list of executed actions.

Here’s the Action Facade:

class Action extends Facade
{
public const KEY = 'action';

protected static function getFacadeAccessor(): string
{
return static::KEY;
}

/**
* Replace the bound instance with a fake.
*/
public static function fake(array|string $actionsToFake = []): ActionFake
{
return tap(new ActionFake(static::getFacadeApplication(), Arr::wrap($actionsToFake)), static function ($fake) {
static::swap($fake);
});
}
}

In this Action Facade class, the getFacadeAccessor method specifies the key to access the facade's underlying service from the service container. The fake method allows replacing the bound instance with a fake for testing purposes. It accepts an array or string of actions to fake and returns an instance of ActionFake, which is then swapped with the current facade instance.

Registration of the Action Facade in AppServiceProvider:

public function register(): void
{
// ... other
$this->app->bind(Action::KEY, function () {
return new ActionManager($this->app);
});
}

Here’s the ActionFake which extends the ActionManager and provides functionality for faking action execution and asserting whether specific actions were dispatched. It also includes methods for testing purposes, such as assertDispatched and assertNotDispatched, to verify the behaviour of action execution.

class ActionFake extends ActionManager
{
protected array $actionsToFake;

public function __construct(Application $app, array $actionsToFake = [])
{
parent::__construct($app);

$this->actionsToFake = $actionsToFake;
}

public function dispatch(object $action): mixed
{
if (in_array(get_class($action), $this->actionsToFake, true)) {
$this->ranActions[] = $action;

return true;
}

return parent::dispatch($action);
}

public function assertDispatched(string $actionClass, ?callable $callback = null): void
{
// Check whether $runActions contain an instance of passed $actionClass
}

public function assertNotDispatched(string $actionClass): void
{
// Check whether $runActions does not contain an instance of passed $actionClass
}
}

Summary

Using Actions in Laravel offers several advantages:

  • Organized Code: Actions help maintain code cleanliness by grouping related tasks into separate classes.
  • Code Reusability: With Actions, you can create reusable chunks of code that can be used across different parts of your application.
  • Easy Testing: Actions simplify testing by allowing you to isolate specific parts of your application and test them independently.

In conclusion, leveraging Actions in Laravel projects can streamline development workflows, improve code quality, and enhance the maintainability of your application.

--

--

Dmytro
Dmytro

Written by Dmytro

Technical Leader, Web and Mobile Developer

No responses yet