Patrones y Arquitectura Modular en Laravel: Controladores Ligeros, Actions, Concerns y Servicios

- Andrés Cruz - EN In english

Una de las quejas más comunes cuando un proyecto Laravel crece es que los controladores terminan convirtiéndose en clases enormes que lo hacen todo: validan datos, ejecutan reglas de negocio, disparan eventos y formatean respuestas. En este artículo vemos cómo evitar ese problema usando tres herramientas complementarias: el patrón Action para encapsular casos de uso, los Concerns (Traits) para reutilizar validaciones y comportamientos transversales, y las Service classes para agrupar operaciones de negocio relacionadas. Todo con ejemplos de código reales, sin teoría vacía.

Patrón Action en Laravel: Controladores Ligeros y Concerns

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 con un verbo que describe la acción a realizar, seguido del recurso sobre el que opera. Por ejemplo: ProcessPayment, CreateUser o UpdateCourse.

En el post anterior, hablamos sobre la importancia de certificarte en Laravel y este artículo te viene de perlas, ya que cubre patrones ampliamente utilizados en proyectos Laravel reales.

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 a largo plazo. El patrón Action resuelve este problema al extraer esa carga operativa y mantener controladores delgados (thin controllers): clases que únicamente orquestan la entrada y la salida, delegando el trabajo real a otras capas especializadas.

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 ServiceProvider. Esa flexibilidad lo hace auténticamente reutilizable sin necesidad de adaptaciones.
  • 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 y haciendo que las suites de pruebas sean más rápidas y confiables.

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. Esto significa que Laravel instancia el Action automáticamente sin que tengas que gestionar su creación manualmente:

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 realizados hasta ese punto se revierten automáticamente (rollback), previniendo estados inconsistentes en la base de datos.

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

Las clases Action 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. Es buena práctica agruparlas por dominio: App/Actions/Payment, App/Actions/Rating, etc.

La idea principal detrás del patrón Action 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, sin duplicar una sola línea de código.

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. Puedes pensar en ellos como "plugins" que se añaden a las clases que los necesitan mediante la directiva use.

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 y no pueden usarse fuera de él.

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. Sin esa petición, simplemente no funcionan.

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 que pueden invocarse desde cualquier capa de la aplicación.

Diferencia Clave entre un Action y un Concern

Dado que ambos conceptos sirven para aislar y reutilizar funcionalidad, es habitual confundirlos al principio. Sin embargo, su propósito y forma de uso difieren de manera bastante clara:

  • 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 que lo necesitan mediante use (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.

Modularización en Laravel: Actions, Concerns y Helpers en Endpoints

Video thumbnail

Al optimizar una REST API o la aplicación en general, la unificación de endpoints es solo la primera capa del trabajo. Si bien consolidar múltiples peticiones repetidas en un solo viaje optimiza la red y el rendimiento HTTP (puedes consultar el apartado de la REST API para más detalles), a nivel de backend surge la necesidad de organizar y desacoplar el código que procesa dicha información.

Arquitectura Modular: Herramientas de Desacoplamiento

Para evitar controladores sobredimensionados (fat controllers) o código duplicado entre diferentes capas del framework, se pueden combinar cuatro estrategias de modularización principales:

  • Actions: Encapsulan un caso de uso o lógica de negocio específica. Son ideales para operaciones complejas como procesar un pago, registrar una transacción o gestionar operaciones CRUD avanzadas. Un Action suele exponer un método de ejecución principal (por ejemplo, handle()).
  • Concerns (Traits): Añaden un comportamiento o responsabilidad transversal reutilizable entre clases. Un Concern no implementa lógica de negocio por sí solo, sino que provee funcionalidades de apoyo, como validaciones personalizadas o formateo de datos.
  • Helpers (Funciones Auxiliares): Funciones globales diseñadas para operaciones recurrentes que necesitan ser invocadas desde múltiples capas (como controladores, componentes de interfaz o vistas Blade).
  • Endpoints Refactorizados: Wrappers de API que consumen las capas internas (Actions/Helpers) y devuelven respuestas formateadas en JSON.

Caso de Estudio: Reutilización de Lógica entre API y Vistas

Consideremos la obtención del carrito de compras. La lógica requiere validar el usuario autenticado, filtrar productos ya adquiridos y calcular el total de la orden. En lugar de duplicar este proceso en el controlador de la API y en el componente de vista, la consulta se encapsula dentro de un Helper:

// Definición en Helper
function get_shopping_cart($user) {
    // Lógica para obtener items, cantidad y precio total
    return [
        'items' => $items,
        'count' => $count,
        'total' => $total
    ];
}

Esta función puede consumirse de manera directa desde cualquier capa de la aplicación:

  • Desde la API: Invocando la función desde el controlador unificado y envolviendo el resultado en un objeto de respuesta JSON (response()->json(...)).
  • Desde la capa de presentación (Blade / Inertia / Livewire): Llamando a la misma función durante la carga inicial del layout para inyectar los datos en el objeto global de la ventana o en el estado del cliente.

Si la operación interna escala en complejidad —por ejemplo, si la consulta de notificaciones requiere filtrar por categorías o interactuar con servicios externos—, la función del Helper puede apoyarse internamente en un Action específico que gestione dicho proceso, manteniendo siempre el principio de responsabilidad única.

No existe una única herramienta universal para estructurar aplicaciones en Laravel. La combinación equilibrada de Actions para lógica de negocio pesada, Concerns para comportamientos compartidos, y Helpers para consultas globales recurrentes, permite construir un sistema completamente modular, mantenible y escalable a largo plazo.

Servicios en Laravel: Qué son, Cuándo Usarlos y Cuándo Evitarlos

Video thumbnail

En el desarrollo con Laravel, la encapsulación de la lógica de negocio es fundamental para mantener aplicaciones limpias, mantenibles y escalables. A diferencia de los controladores o modelos, un servicio (o service class) es una clase sin estado dedicada exclusivamente a procesar reglas de negocio, sin saber nada del mundo exterior que la invoca.

1. Qué es un Servicio en Laravel

Un servicio es una clase PHP que no conoce peticiones HTTP, rutas ni sistemas de plantillas. Su rol principal es actuar como el núcleo de procesamiento, aislando la lógica compleja lejos del transporte HTTP.

  • Se ubican habitualmente en el directorio app/Services/.
  • Se resuelven a través del contenedor de servicios de Laravel mediante inyección de dependencias.
  • Carecen de estado interno entre peticiones independientes.
  • No existe un comando nativo php artisan make:service, ya que se tratan como clases puras creadas mediante php artisan make:class.

Un servicio en Laravel es una clase sin estado que encapsula lógica de negocio y se inyecta donde se necesita. Sirve para que tus controllers se limiten a pedir y devolver, tu lógica quede testeable sin pasar por HTTP, y el mismo código sirva desde la web, la consola y las colas. Pero no todo controller merece un servicio: crearlos sin un motivo claro produce capas vacías que solo complican el código.

2. Por Qué son Importantes

Delegar responsabilidades en servicios aporta beneficios arquitectónicos directos:

  • Controladores ligeros: Los controladores se limitan estrictamente a recibir la petición, delegar en el servicio y retornar una respuesta.
  • Independencia del canal de entrada: Una misma regla de negocio encapsulada en un servicio puede ser invocada de forma idéntica desde la web, un comando de Artisan, una cola de trabajos o una prueba unitaria.
  • Facilidad de prueba: Permiten verificar el comportamiento de la lógica de negocio en milisegundos, sin necesidad de levantar bases de datos ni simular peticiones HTTP.
  • Aislamiento del framework: El dominio de la aplicación permanece desacoplado de las herramientas específicas de Laravel, lo que facilita migraciones y refactorizaciones futuras.
// Antes: la lógica vive en el controller
public function store(StoreOrderRequest $request): JsonResponse
{
    $order = Order::create($request->validated());

    if ($order->total() > 1000 && ! $request->user()->hasDiscount()) {
        $order->update(['status' => 'manual_review']);
    }

    foreach ($order->items as $item) {
        Inventory::where('sku', $item->sku)->decrement('stock', $item->quantity);
    }

    return response()->json($order, 201);
}

Ese método mezcla validación, reglas de negocio y efectos secundarios. Probar únicamente la regla del > 1000 exige armar un request HTTP completo y una base de datos con inventario preconfigurado.

// Después: el controller coordina, el service decide
public function store(StoreOrderRequest $request, OrderService $orders): JsonResponse
{
   $order = $orders->place(
       items: $request->validated('items'),
       customer: $request->user(),
   );
   return response()->json($order, 201);
}

Ahora probar la regla es tan sencillo como llamar directamente a un método. Sin HTTP, sin request, sin magia.

3. Anatomía y Diferencia con Otras Piezas (como Actions)

Mientras que una Action está orientada a una única responsabilidad de uso puntual (un caso de uso específico con pocos métodos), un servicio agrupa operaciones de negocio relacionadas que comparten un contexto común. Un servicio bien escrito utiliza tipos estrictos, propiedades de solo lectura (readonly) y contratos basados en interfaces para desacoplar implementaciones externas.

<?php

namespace App\Services;

use App\Contracts\InventoryGateway;
use App\Exceptions\InsufficientStock;

class OrderService
{
    public function __construct(
        private readonly InventoryGateway $inventory,
    ) {
    }

    /**
     * Coloca un pedido y descuenta el inventario.
     *
     * @param  array<int, array{sku: string, quantity: int}>  $items
     */
    public function place(array $items, Customer $customer): Order
    {
        $this->inventory->reserve($items);

        try {
            return Order::create([
                'customer_id' => $customer->id,
                'items' => $items,
            ]);
        } catch (Throwable $exception) {
            $this->inventory->release($items);

            throw $exception;
        }
    }
}

Cuatro cosas a destacar:

  1. Promoción de propiedades con readonly. El constructor es un constructor real y el objeto es inmutable desde fuera.
  2. Tipos explícitos en todo. La firma es la documentación.
  3. Dependencias por contrato, no por implementación. InventoryGateway es una interfaz: el test inyecta un doble, producción inyecta la implementación real.
  4. No hay request(), ni response(), ni session(). Nada de lo que aparece aquí depende de que haya una petición HTTP en curso.

Los contratos (interfaces) se crean con php artisan make:interface:

$ php artisan make:interface Contracts/InventoryGateway

4. El Contenedor de Servicios y el Binding

El contenedor de Laravel administra la resolución de dependencias. Cuando un servicio depende de contratos o interfaces, es necesario registrar el enlace correspondiente en los proveedores de servicios (como AppServiceProvider):

  • bind(): Genera una nueva instancia cada vez que se solicita. Es la opción predeterminada adecuada para servicios sin estado.
  • singleton(): Mantiene una única instancia compartida en toda la aplicación; útil para clientes HTTP u objetos costosos de inicializar. Debe evitarse si la clase almacena estado mutable, para prevenir filtraciones de datos entre peticiones.

5. Cuándo SÍ Usar un Servicio

Es recomendable implementar un servicio cuando se cumple alguna de las siguientes condiciones:

  • La lógica de negocio debe reutilizarse a través de múltiples canales de entrada (web, API, consola, colas).
  • El flujo operativo involucra condiciones encadenadas complejas o múltiples pasos secuenciales.
  • Se requiere aislar la lógica para someterla a pruebas unitarias rápidas y sin dependencias externas.
  • La operación maneja integraciones de infraestructura con múltiples posibles implementaciones (por ejemplo, pasarelas de pago o proveedores de almacenamiento en la nube).

6. Cuándo NO Usar un Servicio (Sobre-ingeniería)

Evita crear servicios innecesarios en los siguientes escenarios:

  • CRUDs disfrazados: Clases que únicamente replican métodos nativos de Eloquent sin aportar reglas de negocio (por ejemplo, métodos que solo llaman a Model::create()).
  • // No es un servicio. Es un método de Eloquent con más pasos.
    class UserService
    {
        public function find(int $id): User
        {
            return User::findOrFail($id);
        }
    
        public function create(array $data): User
        {
            return User::create($data);
        }
    }
  • Servicios anémicos: Clases genéricas con nombres como DataService o HelperService que no representan una intención clara de negocio.
  • Operaciones triviales: Lógicas simples de una o dos líneas que pueden resolverse directamente en el controlador o mediante los métodos nativos del framework.
  • Un condicional suelto:
  • // Un if no necesita su propia clase
    if ($order->total() > 0) {
       $order->update(['status' => 'paid']);
    }
  • La cadena de envoltorios:
  • // Este service solo llama a otro service. Bórralo.
    class CreateOrderService
    {
       public function __construct(private PlaceOrderService $place) {}
       public function execute(array $data): Order
       {
           return $this->place->handle($data);
       }
    }

7. Errores Comunes en la Implementación

  1. Incluir HTTP en el servicio: Manipular objetos de tipo response() o request() dentro de la clase de servicio rompe su versatilidad; es preferible lanzar excepciones de dominio y dejar que el controlador las traduzca en respuestas HTTP apropiadas.
  2. Inyectar modelos de Eloquent por el constructor: Puede dejar instancias obsoletas cargadas en memoria; es más seguro pasar los identificadores o los modelos como argumentos directos de los métodos.
  3. Tragar excepciones: Capturar errores con bloques try/catch vacíos o que retornan valores nulos oculta fallos críticos en el flujo, haciendo que los bugs sean mucho más difíciles de rastrear.
  4. Confundir servicios con helpers: Un helper es una función pura sin estado y sin identidad de negocio, mientras que un servicio modela procesos complejos con dependencias y contratos bien definidos.

8. Checklist de Decisión

Antes de crear un nuevo servicio, evalúa las siguientes preguntas:

  • ¿La lógica se utilizará en más de un canal de la aplicación?
  • ¿Puede probarse de forma aislada sin requerir una petición HTTP?
  • ¿El nombre de la clase describe una intención de negocio específica y no un contenedor genérico?
  • ¿Se puede explicar su responsabilidad principal en una sola frase?

Si la respuesta general es afirmativa, el uso de un servicio está plenamente justificado. De lo contrario, es probable que se trate de una capa de abstracción innecesaria que solo añade ruido al proyecto.

9. Ejemplo Completo con Interfaz y Test

El contrato:

<?php

namespace App\Contracts;

interface InventoryGateway
{
    public function reserve(array $items): void;

    public function release(array $items): void;
}

La implementación real:

<?php

namespace App\Services;

use App\Contracts\InventoryGateway;
use Illuminate\Support\Facades\Http;

class HttpInventoryGateway implements InventoryGateway
{
    public function reserve(array $items): void
    {
        $response = Http::post(config('services.warehouse.url').'/reservations', ['items' => $items]);

        $response->throw();
    }

    public function release(array $items): void
    {
        Http::post(config('services.warehouse.url').'/reservations/release', ['items' => $items])->throw();
    }
}

El servicio de negocio:

<?php

namespace App\Services;

use App\Contracts\InventoryGateway;
use App\Exceptions\InsufficientStock;
use App\Models\Customer;
use App\Models\Order;

class OrderService
{
    public function __construct(private readonly InventoryGateway $inventory)
    {
    }

    /**
     * @param  array<int, array{sku: string, quantity: int}>  $items
     *
     * @throws InsufficientStock
     */
    public function place(array $items, Customer $customer): Order
    {
        try {
            $this->inventory->reserve($items);
        } catch (Throwable $exception) {
            throw new InsufficientStock(previous: $exception);
        }

        return Order::create([
            'customer_id' => $customer->id,
            'items' => $items,
            'total' => $this->calculateTotal($items),
        ]);
    }

    /**
     * @param  array<int, array{sku: string, quantity: int, price: int}>  $items
     */
    public function calculateTotal(array $items): int
    {
        return array_reduce(
            $items,
            fn (int $carry, array $item): int => $carry + ($item['price'] * $item['quantity']),
            0,
        );
    }
}

El binding en AppServiceProvider:

public function register(): void
{
    $this->app->singleton(InventoryGateway::class, HttpInventoryGateway::class);
}

El test:

<?php

use App\Exceptions\InsufficientStock;
use App\Models\Customer;
use App\Services\OrderService;

it('crea un pedido y reserva el inventario', function () {
    $gateway = Mockery::mock(InventoryGateway::class);
    $gateway->shouldReceive('reserve')->once()->with([
        ['sku' => 'ABC-1', 'quantity' => 2, 'price' => 500],
    ]);

    $order = (new OrderService($gateway))->place(
        items: [['sku' => 'ABC-1', 'quantity' => 2, 'price' => 500]],
        customer: Customer::factory()->create(),
    );

    expect($order->total)->toBe(1000);
});

it('traduce un fallo de inventario a una excepcion de dominio', function () {
    $gateway = Mockery::mock(InventoryGateway::class);
    $gateway->shouldReceive('reserve')->andThrow(new RuntimeException('timeout'));

    expect(fn () => (new OrderService($gateway))->place([], Customer::factory()->create()))
        ->toThrow(InsufficientStock::class);
});

Ninguno de esos tests toca HTTP, ni rutas, ni el contenedor de servicios. Por eso corren rápido y por eso fallan de forma útil cuando algo se rompe.

Aprende a implementar el patrón Action en Laravel para desacoplar la lógica de negocio, organizar validaciones con Concerns (Traits) y decidir cuándo usar un Service. Ejemplos prácticos con transacciones, inyección de dependencias e interfaces.


Ú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.