Master Action Classes to remove business logic from your controllers. Learn to build testable, single-purpose classes for a scalable Laravel architecture.
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.
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.
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.
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.
PHPnamespace 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:
PHPpublic function store(CreateSubscriptionRequest $request, CreateSubscriptionAction $createSubscription) { $user = $createSubscription($request->validated()); return response()->json(['user' => $user], 201); }
Because CreateSubscriptionAction uses standard constructor injection, testing becomes trivial. You no longer need to simulate an HTTP request to verify your subscription logic.
PHPpublic 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']); }
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.Illuminate\Http\Request into an Action. Actions should be agnostic of the transport layer. Pass primitive types or Data Transfer Objects (DTOs) instead.ProcessRefundAction or GenerateMonthlyReportAction rather than generic ones like UserAction.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.
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.
__invoke: Makes your classes callable as functions for cleaner syntax.Up next: We will explore how to pass data between these Actions safely and efficiently by Utilizing Data Transfer Objects (DTOs).
Master advanced Eloquent scopes to encapsulate complex business logic, chain query filters, and maintain clean, expressive models in your Laravel SaaS platform.
Read moreMove beyond "Fat Controllers" and embrace Domain-Driven Design (DDD). Learn how to identify business boundaries and scale your Laravel SaaS architecture.
Custom Middleware Development
Database Connection Pooling
Handling Large Data Exports
Security Header Configuration
Database Sharding Concepts
Real-time Data Synchronization
Database Deadlock Prevention
Managing Third-Party API Integrations