Arquitectura en Laravel 13: cuándo usar Actions, Services y DTOs (con ejemplos)
Tu controlador de Laravel empezó con diez líneas y ahora valida, cobra, manda emails y formatea respuestas: tiene 150 líneas y cada cambio asusta. Actions, Services y DTOs son el vocabulario con el que la comunidad ordena esa lógica, y esta guía te da reglas claras para elegir cada pieza.
Lo vemos con un refactor paso a paso de un checkout de e-commerce, del controlador gordo al controlador delgado.
El problema: los controladores gordos
Todo proyecto Laravel empieza limpio: controladores pequeños, modelos sin sorpresas. Luego llegan las features, y el método store que validaba tres campos termina haciendo de todo. Ese controlador gordo no es un problema estético: es el lugar donde nacen los bugs, los tests difíciles y el miedo a tocar código que funciona.
Señales de que tu controlador pide refactor
Hay síntomas claros: el método supera las cincuenta líneas, mezcla validación con lógica de negocio y con formateo de respuesta, repite código que ya existe en otro controlador, o depende de servicios externos difíciles de simular en tests. Cuando una operación se duplica en dos puntos de entrada, el controlador ya dejó de ser el sitio correcto.
Por qué "ponerlo todo en el modelo" tampoco funciona
La tentación natural es mover la lógica al modelo Eloquent y listo. El modelo se convierte entonces en un cajón con responsabilidades que no le tocan: enviar emails, llamar a la pasarela de pago o generar PDFs. El modelo describe datos y reglas de ese dato; las operaciones de negocio merecen otro sitio.
Lo que la lógica de negocio necesita: un lugar propio y testeable
La lógica de negocio necesita un lugar propio, con nombre claro y sin dependencias del HTTP. Si la operación vive en una clase independiente, puedes ejecutarla desde un controlador, desde un job de cola o desde un comando, y testearla sin levantar una petición web. De eso tratan exactamente las Actions, los Services y los DTOs.
Las tres piezas: Actions, Services y DTOs
Son tres herramientas distintas que suelen confundirse. La definición corta: un Action es una operación, un Service es un grupo de operaciones cohesionadas, y un DTO es un contrato de datos que viaja entre capas.
Action: una operación, una clase invocable
Un Action encapsula una única operación de negocio. La convención más habitual es una clase invocable: el contenedor la resuelve y la llamas con la sintaxis de función. Un ejemplo mínimo:
<?php
namespace App\Actions\Order;
use App\Models\Order;
class MarkOrderAsPaid
{
public function __invoke(Order $order): void
{
$order->update(['status' => 'paid', 'paid_at' => now()]);
}
}Service: un grupo de operaciones sobre un dominio
Un Service agrupa varias operaciones relacionadas con un mismo dominio o integración. El ejemplo típico es el proveedor de pagos: cobrar, reembolsar y consultar un cargo viven juntos porque comparten configuración y estado.
<?php
namespace App\Services;
class PaymentService
{
public function charge(int $amountCents, string $token): array { /* ... */ }
public function refund(string $chargeId): array { /* ... */ }
}DTO: el contrato de datos entre capas
Un DTO (Data Transfer Object) transporta datos entre capas con un contrato explícito: propiedades tipadas, sin lógica de negocio y normalmente inmutable. Cuando un request, una API externa y una cola necesitan el mismo paquete de datos, el DTO es la pieza que los normaliza.
Cuándo usar cada uno: reglas de decisión
La pregunta de oro no es "¿Actions o Services?", sino "¿qué problema estoy resolviendo?". Estas tres reglas cubren el 90 por ciento de los casos.
Action cuando la operación se reutiliza desde varios puntos de entrada
Si la misma operación la disparan un controlador, un job de cola y un comando, esa lógica va a un Action. Escribes la operación una vez y todos los puntos de entrada la llaman; además, Laravel soporta el patrón de una clase por acción en sus Single Action Controllers con make:controller --invokable, que encaja con esta misma idea.
Service para integraciones y lógica con estado
Cuando trabajas con una integración externa o un dominio con varias operaciones que comparten configuración, el Service es el sitio natural: PaymentService con charge y refund, MailService con sus métodos, o un servicio de facturación que agrupa todo lo relacionado. El Service guarda la dependencia inyectada una vez y expone sus operaciones.
DTO cuando los datos cruzan capas o llegan de varias fuentes
Usa un DTO cuando quieras que una Action o un Service reciba un paquete de datos tipado en vez de un array suelto, o cuando el mismo conjunto de datos llega desde el request, desde una API externa o desde una cola. El DTO convierte tres formatos distintos en un único contrato.
En la práctica: refactor paso a paso de un checkout
La teoría se entiende con el código delante. Tomemos un checkout genérico de e-commerce y llevémoslo de controlador gordo a estructura limpia.
Antes: el controlador de 150 líneas
El punto de partida: el método store valida el carrito, crea el pedido, cobra con la pasarela, manda el email, dispara un evento, registra en log y devuelve JSON. Todo dentro del controlador, con la validación inline y las dependencias resueltas con facades. Funciona, pero cada cambio nuevo rompe algo.
Paso 1: dejar la validación en su Form Request
Lo primero es sacar la validación del controlador a un Form Request. El controlador recibe un objeto ya validado y se ahorra diez líneas de reglas; el tema completo de validación con Form Requests tiene su propio artículo en este blog si quieres profundizar.
Paso 2: modelar los datos con un DTO
La operación necesita el carrito, el cliente y el método de pago. En vez de pasar tres argumentos sueltos o un array, defines un DTO que agrupa el payload:
<?php
namespace App\Data;
final readonly class CheckoutData
{
public function __construct(
public array $items,
public int $customerId,
public string $paymentToken,
) {}
}Si prefieres validación integrada y Data::from(), spatie/laravel-data consigue el mismo DTO con menos código, como verás más adelante.
Paso 3: mover la operación principal a una Action
La operación que crea el pedido, cobra y dispara el evento se mueve a un Action invocable. El controlador ya no sabe cómo se cobra: solo llama.
<?php
namespace App\Actions\Order;
use App\Data\CheckoutData;
use App\Models\Order;
class PlaceOrder
{
public function __invoke(CheckoutData $data): Order
{
// crea el pedido, cobra y dispara el evento OrderPlaced
}
}Paso 4: agrupar lo que queda en un Service
Si dentro del flujo hay varias llamadas a la pasarela (cobrar, y luego opcionalmente reembolsar), esas operaciones cohesionadas se agrupan en PaymentService. El Action usa el Service, el Service usa la pasarela, y cada pieza tiene una sola responsabilidad.
Después: el controlador como pegamento HTTP
El controlador final queda reducido a su papel real: recibir la petición, delegar y responder.
<?php
namespace App\Http\Controllers;
use App\Actions\Order\PlaceOrder;
use App\Data\CheckoutData;
use App\Http\Requests\CheckoutRequest;
class CheckoutController extends Controller
{
public function __invoke(CheckoutRequest $request, PlaceOrder $placeOrder)
{
$order = $placeOrder(new CheckoutData(
items: $request->validated('items'),
customerId: $request->user()->id,
paymentToken: $request->validated('payment_token'),
));
return response()->json($order, 201);
}
}El controlador valida con el Form Request, construye el DTO, llama a la Action y responde. Nada más. La lógica quedó en clases con nombre, probables y reutilizables.
Lo que Laravel hace gratis: el contenedor
Lo mejor de este estilo es que no requiere registro manual: el contenedor de servicios de Laravel resuelve las dependencias por ti.
Auto-wiring: el type-hint en el constructor resuelve la dependencia
Si type-hint-eas una clase en el constructor o en la firma de un método de un controlador, el contenedor la instancia solo con sus dependencias, sin registrarla en ningún sitio. Por eso PlaceOrder aparece directamente en la firma del método: Laravel lo construye, le inyecta lo que necesita y te lo entrega listo.
Interfaces y binding en un ServiceProvider
El auto-wiring funciona cuando la dependencia es una clase concreta. Si programas contra una interfaz, por ejemplo para cambiar de pasarela de pago, registras el binding en un ServiceProvider con $this->app->bind(...) o singleton, y el contenedor resuelve la interfaz con la implementación que elijas.
Laravel 13 y los PHP Attributes: convención sobre configuración
Laravel 13, lanzado en marzo de 2026 con PHP 8.3 como versión mínima, sigue empujando los PHP Attributes como mecanismo de configuración declarativa. Ese mismo espíritu aplica a tu código: las clases readonly y los atributos nativos de PHP moderno hacen que Actions, Services y DTOs se escriban con menos ceremonia y más intención. El resumen de novedades de Laravel 13 de este blog repasa el resto de la release.
DTOs en 2026: PHP moderno y spatie/laravel-data
Los DTOs no son nuevos, pero PHP moderno los hizo triviales, y Laravel 13 los aprovecha sin necesidad de paquetes en la mayoría de los casos.
readonly class y constructor promotion: DTO sin dependencias
Con la promoción de propiedades del constructor y las clases readonly, un DTO nativo se escribe en seis líneas y queda inmutable de verdad: propiedades tipadas que no pueden cambiar después de construirse. Para contratos internos entre tus propias capas, es la opción más simple y sin dependencias.
spatie/laravel-data: validación integrada y Data::from()
Cuando el DTO tiene que validar su payload o crearse desde varias fuentes, spatie/laravel-data (v4) es la opción habitual: la clase Data valida los datos antes de construirse y permite crearla con Data::from() desde un request validado, un array o un modelo. Añade dependencia, pero a cambio integra validación, transformación y factories.
Un DTO para varias fuentes: request, API externa y cola
El caso donde el DTO brilla es el dato que llega por varios caminos: el mismo checkout lo dispara un usuario en la web, una API externa o un job reintentado. Cada fuente entrega el payload en un formato distinto; el DTO lo normaliza a un único contrato y la Action no sabe ni le importa de dónde vino.
Errores comunes y cuándo NO aplicar estos patrones
Estos patrones resuelven un problema real, pero mal aplicados crean otro. Los anti-patrones clásicos son fáciles de reconocer.
El Service dios y el Action que lo llama todo
Un Service con cuarenta métodos de dominios distintos es un controlador gordo con otro nombre, y un Action que encadena diez operaciones deja de ser una operación única. Si la clase no cabe en una frase ("esto cobra", "esto reembolsa"), está haciendo demasiado.
Abstraer un CRUD trivial: no hace falta
Un CRUD simple sin lógica de negocio no necesita capas: el controlador con su Form Request y su modelo bastan. Crear Actions y Services para un create-estándar es sobreingeniería que añade archivos sin aportar claridad.
La regla práctica para equipos pequeños: extraer cuando duela
La regla de oro para proyectos medianos y equipos pequeños: empieza simple y extrae la primera clase cuando duela. Cuando un controlador supere las cincuenta líneas, cuando una operación se duplique o cuando un test necesite simular una llamada externa, ese es el momento de crear la Action, el Service o el DTO. No antes.
Cómo probar Actions y Services
Esta estructura paga su deuda en los tests. Una Action se prueba como una clase normal: la instancias con dependencias falsas y verificas el resultado, sin tocar HTTP. Si cobra a través de PaymentService, mockeas el service con la facade o con un doble de test; si dispara un evento, usas Event::fake(). El resultado: tests rápidos que describen la lógica de negocio, no el tráfico web. El testing con Pest de este blog y las guías de eventos y de colas de Laravel 13 te dan el resto del contexto.
Conclusión
Actions, Services y DTOs no son obligatorios ni excluyentes: son el vocabulario para decidir dónde vive cada pieza de la lógica de negocio en Laravel 13. Usa un Action para la operación reutilizable, un Service para el grupo cohesionado y un DTO para el contrato de datos, y deja que el contenedor haga el trabajo sucio del auto-wiring. Tu próximo refactor de controlador gordo empezará por mover la primera responsabilidad a una clase con nombre, y el resto del flujo seguirá solo.