Back to Blog
Lesson 3 of the Advanced Laravel: Architecture, Scaling & Performance course
LaravelJune 26, 20263 min read

Implementing Action Classes: Clean Architecture in Laravel

Master Action Classes to remove business logic from your controllers. Learn to build testable, single-purpose classes for a scalable Laravel architecture.

LaravelDDDArchitectureRefactoringClean CodeActionsphpbackend

Previously in this course, we discussed Transitioning from MVC to DDD and Defining Bounded Contexts. We established the importance of domain boundaries; now, we need a mechanism to execute logic within those boundaries without bloating our HTTP layer.

In a high-traffic SaaS, controllers should be "thin." Their only responsibility is to handle the HTTP request, trigger a domain operation, and return a response. When we shove business logic—like calculating subscription prorations or sending complex welcome emails—into controllers, we create "Fat Controllers" that are impossible to test in isolation.

Encapsulating Logic with Action Classes

Action Classes are single-purpose, invokable classes that encapsulate exactly one business process. By adopting this pattern, we move toward a more modular architecture, similar to the strategies discussed in Implementing the Service Layer in Laravel for Maintainable Code.

Unlike a Service class, which might group several related methods (e.g., UserService with create, delete, and update), an Action is strictly single-responsibility. It is usually implemented using the __invoke magic method, which allows you to call the object as if it were a function.

The "Fat" Controller Problem

Consider a standard SubscriptionController. It likely handles validation, database creation, payment gateway integration, and notification dispatching.

PHP
#6A9955">// BEFORE: A bloated controller
public function store(Request $request) {
    $user = User::create($request->validated());
    $payment = Stripe::charge($user, $request->amount);
    $user->notify(new WelcomeEmail());
    return response()->json(['status' => 'success']);
}

This controller is hard to unit test because it requires mocking the entire framework request lifecycle and external API calls.

Implementing an Invokable Action

Let's refactor the subscription logic into a dedicated Action Class. We will use constructor-based dependency injection to keep our code clean and testable.

PHP
namespace App\Actions\Billing;

use App\Models\User;
use App\Services\PaymentGateway;
use App\Notifications\WelcomeEmail;

class CreateSubscriptionAction
{
    public function __construct(
        protected PaymentGateway $gateway
    ) {}

    public function __invoke(array $data): User
    {
        $user = User::create($data);
        
        $this->gateway->charge($user, $data['amount']);
        
        $user->notify(new WelcomeEmail());

        return $user;
    }
}

Now, your controller becomes a thin wrapper:

PHP
public function store(CreateSubscriptionRequest $request, CreateSubscriptionAction $createSubscription)
{
    $user = $createSubscription($request->validated());
    
    return response()->json(['user' => $user], 201);
}

Dependency Injection and Testing

Because CreateSubscriptionAction uses standard constructor injection, testing becomes trivial. You no longer need to simulate an HTTP request to verify your subscription logic.

PHP
public function test_can_create_subscription()
{
    $mockGateway = Mockery::mock(PaymentGateway::class);
    $mockGateway->shouldReceive('charge')->once();

    $action = new CreateSubscriptionAction($mockGateway);
    $user = $action(['name' => 'John Doe', 'amount' => 100]);

    $this->assertDatabaseHas('users', ['name' => 'John Doe']);
}

Common Pitfalls to Avoid

  1. Injecting Actions into Actions: If you find yourself calling ActionA inside ActionB, you might be missing a Domain Service. Actions should ideally be the entry point to your domain, not a chain of dependencies.
  2. Passing Request Objects: Never pass Illuminate\Http\Request into an Action. Actions should be agnostic of the transport layer. Pass primitive types or Data Transfer Objects (DTOs) instead.
  3. Naming Ambiguity: Use descriptive names like ProcessRefundAction or GenerateMonthlyReportAction rather than generic ones like UserAction.

Hands-on Exercise

In our running project, locate the RegistrationController. Identify the logic that handles user creation and the assignment of a default "Free" plan. Extract this logic into an App\Actions\Auth\RegisterUserAction class. Ensure you inject any necessary dependencies (like a PlanRepository) into the constructor.

Advancing the Project

By extracting this registration logic, you've taken the first step in cleaning up the core of our SaaS platform. This structure will allow us to trigger the same registration logic from a CLI command or a webhook listener without duplicating code.

Refactoring into Action Classes is essential for Clean Code practices, ensuring our codebase remains maintainable as we scale.

Recap

  • Move logic out: Controllers should only handle requests and responses.
  • Single Responsibility: Each Action class does exactly one thing.
  • Use __invoke: Makes your classes callable as functions for cleaner syntax.
  • Inject dependencies: Use the constructor to keep classes testable.

Up next: We will explore how to pass data between these Actions safely and efficiently by Utilizing Data Transfer Objects (DTOs).

Similar Posts