Action Pattern in Laravel: Lightweight Controllers and Concerns

- Andrés Cruz - ES En español

Action Pattern: Centralizing Business Logic in Laravel

Video thumbnail

An Action is a class designed under the Single Responsibility Principle. Its objective is to encapsulate a specific use case or a complex business rule that requires multiple operational steps, keeping them in a single coherent and reusable place.

By convention, these classes are named using a verb that describes the action to be performed, followed by the resource it operates on. For example: ProcessPayment, CreateUser, or UpdateCourse.

The Bloated Controllers Problem

When processing a complex operation — such as registering a payment on an educational platform — the system must execute a transactional sequence of chained steps:

  1. Verify the user's account status.
  2. Connect to an external payment gateway.
  3. Register the transaction in the local database.
  4. Deduct stock or update course access permissions.
  5. Trigger system events and send notification emails.

Placing all this logic inside a controller's store method bloats it quickly and complicates maintenance. The Action pattern resolves this issue by extracting that operational workload to maintain thin controllers: classes that only orchestrate input and output, delegating the actual work to other layers.

Architectural Advantages of Actions

  • Context Agnosticism: An Action does not depend on an HTTP request. It can be invoked from a REST API controller, a hybrid web application (with Vue or Inertia), an Artisan console command, a queued Job, or a Service Provider. This flexibility makes it genuinely reusable.
  • Ease of Unit Testing: Being isolated from the web infrastructure, business logic becomes highly testable through pure unit tests. This reduces the need to mock HTTP requests or sessions, considerably simplifying the testing process.

Practical Implementation and Dependency Injection

Thanks to Laravel's service container, Action classes can be injected directly as dependencies into controller methods, leveraging the framework's automatic resolution (autowiring):

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Actions\ProcessCoursePayment;
use Illuminate\Http\Request;

class PaymentController extends Controller
{
    /**
     * Registers a payment by injecting the corresponding Action.
     */
    public function store(Request $request, ProcessCoursePayment $processPayment)
    {
        $request->validate([
            'amount' => 'required|numeric',
            'course_id' => 'required|integer',
        ]);

        // Execution of the use case passing the sanitized data
        $payment = $processPayment->handle($request->all(), $request->user());

        return response()->json([ // or Resource Class
            'success' => true,
            'data' => $payment
        ], 201);
    }
}

The Structure of a Transactional Action

Inside the Action, logic is typically executed within a database transaction using DB::transaction(). This guarantees system integrity in the event of any failure during the sequence of operations: if a step fails, all changes are automatically rolled back.

namespace App\Actions;

use App\Models\User;
use App\Models\Payment;
use Illuminate\Support\Facades\DB;
use App\Events\PaymentProcessedSuccessfully;

class ProcessCoursePayment
{
    /**
     * Executes the logical processing of the payment.
     */
    public function handle(array $data, ?User $user): Payment
    {
        // A transaction is used to ensure all steps are consolidated
        return DB::transaction(function () use ($data, $user) {
            
            // 1. Persistence of the main entity
            $payment = Payment::create([
                'user_id' => $user?->id,
                'amount' => $data['amount'],
                'course_id' => $data['course_id'],
                'status' => 'completed',
            ]);

            // 2. Dispatching system events (Handling notifications, emails, etc.)
            event(new PaymentProcessedSuccessfully($payment));

            return $payment;
        });
    }
}

Delimitation of Responsibilities: Pure ORM-bound logic — such as local relationships, scopes, or mutators — should remain within the Eloquent model. In contrast, transactional operations involving multiple models, integration event management, or sending emails should be completely delegated to the Action class.

Action Pattern to Slim Down Controllers

Previously, I presented the use of the Action pattern to centralize business logic. These classes are usually located in the App/Actions folder, following whichever hierarchy and organizational structure you prefer to avoid cluttering the project root with scattered files.

The main goal of Actions is to relieve the workload in controllers. In the same way we create services for models that become too heavy, the same applies to controllers: when they contain too much logic, we can encapsulate those operations into an independent Action class. The result is a much leaner controller with modular, easily testable code; the only tradeoff is a slightly higher number of files in the project, which is a well-justified cost.

For example, in a payment processing workflow — which is often laborious due to the number of chained steps involved — moving that logic into an Action prevents bloating the controller and makes the process completely reusable. That same Action class can be invoked from a REST API, an internal Artisan command, or directly from the administration panel to process manual payments.

Organization and Decoupling with Concerns (Traits)

Video thumbnail

We can take this level of modularization a step further using another widely adopted concept in the Laravel ecosystem: Concerns. In PHP, a Concern is simply a Trait — the mechanism that allows simulating aspects of multiple inheritance — grouped under the App/Concerns folder.

The purpose of a Concern is to add a specific responsibility or behavior to a class without causing it to grow uncontrollably. Each Concern extracts concrete functionality to avoid code duplication and keep classes focused on a single task.

A scenario where a Concern is particularly useful is in isolating validation rules inside an Action. Taking the creation of a ratings CRUD as an example:

app/Concerns/RatingValidationRules.php

<?php

namespace App\Concerns;

use App\Models\Book;
use App\Models\Tutorial;
use Illuminate\Validation\Rule;

trait RatingValidationRules
{
    /**
     * Get the validation rules for creating a rating.
     *
     * @return array<string, array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>>
     */
    protected function createRatingRules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'description' => ['required', 'string', 'min:30'],
            'rating' => ['required', 'integer', 'min:1', 'max:5'],
            'rateable_type' => ['required', 'string', Rule::in([Book::class, Tutorial::class])],
            'rateable_id' => ['required', 'integer'],
        ];
    }

    /**
     * Get the validation rules for updating a rating.
     *
     * @return array<string, array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>>
     */
    protected function updateRatingRules(): array
    {
        return [
            'title' => ['sometimes', 'required', 'string', 'max:255'],
            'description' => ['sometimes', 'required', 'string', 'min:30'],
            'rating' => ['sometimes', 'required', 'integer', 'min:1', 'max:5'],
        ];
    }

    /**
     * Get the validation rules for replying to a rating.
     *
     * @return array<string, array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>>
     */
    protected function replyRatingRules(): array
    {
        return [
            'reply_id' => ['required', 'integer', Rule::exists('ratings', 'id')],
            'description' => ['required', 'string'],
        ];
    }
}

The Action needs to validate inputs before processing them. Although we can usually resort to Form Request classes, they present a clear limitation in decoupled architectures: they are bound to the HTTP lifecycle.

Below is an example of consuming the Concern from an Action:

app/Actions/Rating/CreateRating.php

class CreateRating
{
    use RatingValidationRules;

    /**
     * Validate and create a new rating.
     *
     * @param  array<string, mixed>  $input
     */
    public function create(User $user, array $input): Rating
    {
        Validator::make($input, $this->createRatingRules())->validate();

        $rateable = $this->resolveRateable(
            (string) $input['rateable_type'],
            (int) $input['rateable_id'],
        );

        if ($rateable->ratings()->where('user_id', $user->id)->exists()) {
            throw ValidationException::withMessages([
                'rateable_id' => [__('rating.already_rated')],
            ]);
        }
        ***

Why Not Use Form Requests Inside an Action?

The main issue with traditional Form Request classes is their tight coupling to the transport layer: they rely on an active HTTP request (Illuminate\Http\Request) being present in the application's lifecycle.

An Action, by definition, must be agnostic to how it was invoked. It does not care whether the information comes from a web controller, a command line interface (CLI), a background job (Queue), or an API endpoint. Since the Action does not receive the HTTP request directly, mocking a request just to utilize a Form Request wouldn't make any sense. Instead, we isolate manual validations using Validator::make() inside a dedicated Concern, ensuring:

  • Complete decoupling from the HTTP protocol.
  • Ease of performing unit tests and isolated integrations.
  • High modularity and self-contained components.

Key Difference Between an Action and a Concern

Since both concepts serve to isolate and reuse functionality, it is common to confuse them. However, their purpose and usage differ clearly:

  • Action: Represents a single, indivisible use case within the system (for example, CreateRatingAction or ProcessPaymentAction). It is an autonomous class that can be instantiated, invoked, and tested independently.
  • Concern: It is an abstraction — a Trait — that groups reusable methods, properties, or rules to complement another class. It cannot run independently; instead, it is imported and injected into main classes (such as Actions themselves or Eloquent Models).

In summary: to build a modular system in Laravel, Actions execute the core business logic and Concerns provide specific, reusable behaviors supporting that execution. Used together, they maintain a clean, predictable architecture that is easy to scale as the project grows.

Learn how to use the Action pattern in Laravel to centralize business logic, decouple controllers, and organize validations with Concerns (Traits).


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

I agree to receive announcements of interest about this Blog.