Patrón Action en Laravel: Controladores Ligeros y Concerns

- Andrés Cruz - EN In english

Patrón Action: Centralizar la Lógica de Negocio en Laravel

Video thumbnail

Un Action es una clase diseñada bajo el principio de responsabilidad única (Single Responsibility Principle). Su objetivo es encapsular un caso de uso específico o una regla de negocio compleja que requiere múltiples pasos operativos, manteniéndolos en un único lugar coherente y reutilizable.

Por convención, estas clases se nombran utilizando un verbo que describe la acción a realizar, seguido del recurso sobre el que opera. Por ejemplo: ProcessPayment, CreateUser o UpdateCourse.

El Problema de los Controladores Saturados

Cuando se procesa una operación compleja —como el registro de un pago en una plataforma educativa— el sistema debe ejecutar una secuencia transaccional de pasos encadenados:

  1. Verificar el estado de la cuenta del usuario.
  2. Conectarse a una pasarela de pagos externa.
  3. Registrar la transacción en la base de datos local.
  4. Descontar el stock o actualizar los accesos al curso.
  5. Disparar eventos del sistema y enviar correos de notificación.

Colocar toda esta lógica dentro del método store de un controlador lo satura rápidamente y dificulta el mantenimiento. El patrón Action resuelve este problema permitiendo extraer esa carga operativa para mantener controladores delgados (thin controllers): clases que únicamente orquestan la entrada y la salida, delegando el trabajo real a otras capas.

Ventajas Arquitectónicas de las Actions

  • Agnosticismo del Contexto: Un Action no depende de una petición HTTP. Puede ser invocado desde un controlador de la API REST, una aplicación web híbrida (con Vue o Inertia), un comando de consola de Artisan, un Job en cola o un Service Provider. Esa flexibilidad lo hace auténticamente reutilizable.
  • Facilidad de Pruebas Unitarias: Al estar aislada de la infraestructura web, la lógica de negocio se vuelve altamente testeable mediante pruebas unitarias puras. Esto reduce la necesidad de simulaciones (mocks) de peticiones HTTP o sesiones, simplificando considerablemente el proceso de testing.

Implementación Práctica e Inyección de Dependencias

Gracias al contenedor de servicios de Laravel, las clases Action pueden inyectarse directamente como dependencias en los métodos del controlador, aprovechando la resolución automática (autowiring) del framework:

namespace App\Http\Controllers\Api;

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

class PaymentController extends Controller
{
    /**
     * Registra un pago inyectando el Action correspondiente.
     */
    public function store(Request $request, ProcessCoursePayment $processPayment)
    {
        $request->validate([
            'amount' => 'required|numeric',
            'course_id' => 'required|integer',
        ]);

        // Ejecución del caso de uso pasando los datos sanitizados
        $payment = $processPayment->handle($request->all(), $request->user());

        return response()->json([ // o Clase Resources
            'success' => true,
            'data' => $payment
        ], 201);
    }
}

La Estructura del Action Transaccional

Dentro del Action, la lógica se ejecuta habitualmente dentro de una transacción de base de datos mediante DB::transaction(). Esto garantiza la integridad del sistema ante cualquier fallo en la secuencia de operaciones: si un paso falla, todos los cambios se revierten automáticamente (rollback).

namespace App\Actions;

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

class ProcessCoursePayment
{
    /**
     * Ejecuta el procesamiento lógico del pago.
     */
    public function handle(array $data, ?User $user): Payment
    {
        // Se utiliza una transacción para asegurar que todos los pasos se consoliden
        return DB::transaction(function () use ($data, $user) {
            
            // 1. Persistencia de la entidad principal
            $payment = Payment::create([
                'user_id' => $user?->id,
                'amount' => $data['amount'],
                'course_id' => $data['course_id'],
                'status' => 'completed',
            ]);

            // 2. Disparo de eventos del sistema (Manejo de notificaciones, correos, etc.)
            event(new PaymentProcessedSuccessfully($payment));

            return $payment;
        });
    }
}

Delimitación de Responsabilidades: La lógica puramente ligada al ORM —como relaciones locales, scopes o mutators— debe permanecer dentro del modelo de Eloquent. En cambio, las operaciones transaccionales que involucran múltiples modelos, la gestión de eventos de integración o el envío de correos electrónicos deben delegarse por completo a la clase Action.

Patrón Action para Liberar Controladores

Anteriormente te presenté el uso del patrón Action para centralizar la lógica de negocio. Estas clases se ubican habitualmente en la carpeta App/Actions, siguiendo la jerarquía y estructura organizativa que prefieras para evitar saturar la raíz del proyecto con archivos dispersos.

La idea principal de los Actions es liberar la carga de trabajo en los controladores. De la misma manera que creamos servicios para los modelos que se vuelven pesados, lo mismo aplica para los controladores: cuando contienen demasiada lógica, podemos encapsular esas operaciones en una clase Action independiente. El resultado es un controlador mucho más ligero, con código modular y fácil de probar; la única contrapartida es una cantidad ligeramente mayor de archivos en el proyecto, lo cual es un costo ampliamente justificado.

Por ejemplo, en un flujo de procesamiento de pagos —que suele ser laborioso por la cantidad de pasos encadenados que requiere— mover esa lógica a un Action evita saturar el controlador y hace que el proceso sea completamente reutilizable. Esa misma clase Action puede invocarse desde una API REST, desde un comando interno de Artisan o directamente desde el panel de administración para procesar pagos manuales.

Organización y Desacoplamiento con Concerns (Traits)

Video thumbnail

Podemos llevar este nivel de modularización un paso más allá usando otro concepto muy extendido en el ecosistema de Laravel: los Concerns. En PHP, un Concern es simplemente un Trait —el mecanismo que permite simular aspectos de la herencia múltiple— que agrupamos bajo la carpeta App/Concerns.

La finalidad de un Concern es añadir una responsabilidad o comportamiento específico a una clase sin hacerla crecer de forma descontrolada. Cada Concern extrae una funcionalidad concreta para evitar la duplicación de código y mantener las clases enfocadas en una sola tarea.

Un escenario donde un Concern resulta especialmente útil es el aislamiento de las reglas de validación dentro de un Action. Tomando como ejemplo la creación de un CRUD de valoraciones (ratings):

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'],
        ];
    }
}

El Action necesita validar las entradas antes de procesarlas. Aunque normalmente podemos recurrir a las clases Form Request, estas presentan una limitación clara en arquitecturas desacopladas: están atadas al ciclo de vida HTTP.

A continuación, un ejemplo de cómo consumir el Concern desde un 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')],
            ]);
        }
        ***

¿Por Qué No Usar Form Requests Dentro de un Action?

El problema principal con las clases Form Request tradicionales es su acoplamiento a la capa de transporte: dependen de que exista una petición HTTP activa (Illuminate\Http\Request) en el ciclo de vida de la aplicación.

Un Action, por definición, debe ser agnóstico a la forma en que fue invocado. No le importa si la información proviene de un controlador web, una consola de comandos (CLI), una cola de trabajo (Queue) o un endpoint de la API. Dado que el Action no recibe directamente la petición HTTP, simular una petición solo para aprovechar un Form Request no tendría ningún sentido. En su lugar, aislamos las validaciones manuales mediante Validator::make() dentro de un Concern dedicado, lo que garantiza:

  • Desacoplamiento total del protocolo HTTP.
  • Facilidad para realizar pruebas unitarias e integraciones aisladas.
  • Alta modularidad y componentes autónomos.

Diferencia Clave entre un Action y un Concern

Dado que ambos conceptos sirven para aislar y reutilizar funcionalidad, es común confundirlos. Sin embargo, su propósito y uso difieren claramente:

  • Action: Representa un caso de uso único e indivisible dentro del sistema (por ejemplo, CreateRatingAction o ProcessPaymentAction). Es una clase autónoma que puede ser instanciada, invocada y testeada de forma independiente.
  • Concern: Es una abstracción —un Trait— que agrupa métodos, propiedades o reglas reutilizables que complementan a otra clase. No puede ejecutarse de forma independiente, sino que se importa e inyecta dentro de las clases principales (como los propios Actions o los Modelos de Eloquent).

En resumen: para construir un sistema modular en Laravel, los Actions ejecutan la lógica de negocio principal y los Concerns proveen los comportamientos específicos y reutilizables que respaldan dicha ejecución. Usados en conjunto, permiten mantener una arquitectura limpia, predecible y fácil de escalar a medida que el proyecto crece.

Aprende a usar el patrón Action en Laravel para centralizar la lógica de negocio, desacoplar controladores y organizar validaciones con 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:

Acepto recibir anuncios de interes sobre este Blog.