---
title: 'Clases Action, servicios y DTO en Laravel | DevSense'
description: 'Cómo aligerar controladores y modelos gordos en Laravel: clases Action, capa de servicios, DTO con clases readonly, límites entre módulos, transacciones y eventos, y cuándo todo esto es sobreingeniería.'
faq:
    - { question: '¿En qué se diferencia una clase Action de un servicio en Laravel?', answer: 'Una Action describe un único escenario de negocio (realizar un pedido, invitar a un miembro al equipo) y tiene un único método público. Un servicio agrupa operaciones en torno a un recurso o una integración (pasarela de pago, calculadora de precios) y no conoce el escenario completo. La Action llama a los servicios, y no al revés.' }
    - { question: '¿Para qué sirven los DTO si ya existen $request->validated() y los arrays?', answer: 'Un array no dice qué claves contiene ni de qué tipo son: una errata en una clave solo aparece en tiempo de ejecución, el IDE no sugiere nada y el análisis estático no puede ayudar. Un DTO basado en una clase readonly fija el contrato: el tipo de cada campo, su obligatoriedad y su inmutabilidad. La misma Action se puede invocar desde un controlador, un comando de consola, un job de cola y un test.' }
    - { question: '¿Se pueden pasar modelos Eloquent entre módulos?', answer: 'Dentro de un módulo, sí. Entre módulos es mejor pasar DTO o identificadores: el modelo arrastra consigo sus relaciones, consultas lazy y la posibilidad de modificar datos ajenos mediante save(). Un límite hecho de DTO hace explícitas las dependencias y permite cambiar el esquema de las tablas de un módulo sin romper a los vecinos.' }
    - { question: '¿Cuándo las clases Action y los DTO son sobreingeniería?', answer: 'En un CRUD sin reglas de negocio, en paneles de administración y en prototipos. Si un controlador de cinco líneas valida la petición y guarda el modelo, extraerlo a una Action y un DTO añade tres archivos y ninguna regla. Las capas compensan cuando el escenario adquiere invariantes, varios puntos de entrada o efectos secundarios.' }
published: '2026-10-03'
---
# Clases Action, servicios y DTO en Laravel: cómo aligerar controladores y modelos

Casi todos los proyectos Laravel pasan por la misma etapa. `OrderController@store` crece hasta las doscientas líneas: validación, cálculo del precio con código promocional, reserva de stock, cobro, correo al cliente, webhook al CRM. Luego aparece una API para la aplicación móvil y esas doscientas líneas se copian a un segundo controlador. Después llega un comando de consola para importar pedidos desde un marketplace: tercera copia. Seis meses más tarde, en una de las copias se corrige un bug de redondeo, y en las otras dos no.

En paralelo crece el modelo `Order`: contiene los métodos `send()`, `refund()`, `syncWithCrm()`, manejadores de eventos y un par de peticiones HTTP. Solo se puede probar a través de HTTP, y cualquier cambio da miedo. Este artículo trata de cómo repartir ese código en capas: **clases Action** para los escenarios, **servicios** para las operaciones reutilizables y **DTO** para los datos que circulan entre ellos. Y de dónde detenerse para no convertir un CRUD sencillo en un monolito corporativo de cuarenta interfaces.

**Guías relacionadas:** [Antipatrones de diseño](design-antipatterns) · [Patrones estructurales GoF](structural-design-patterns) · [Integraciones resilientes](resilient-external-integrations) · [Colas de Laravel en producción](laravel-queues-production)

## Contenido

* [Síntomas de un controlador gordo y un modelo gordo](#symptoms)
* [Mapa de capas: quién se encarga de qué](#layers)
* [DTO: un contrato en lugar de un array](#dto)
* [Clases Action: un escenario, una clase](#actions)
* [Capa de servicios: cuándo hace falta de verdad](#services)
* [El controlador después de la refactorización](#thin-controller)
* [Una lógica, muchas entradas: comandos, jobs, tests](#reuse)
* [Qué se queda en el modelo](#models)
* [Límites entre módulos: DTO entre contextos](#modules)
* [Cuándo es sobreingeniería](#when-not)
* [Cómo refactorizar de forma gradual](#migration)
* [Errores frecuentes](#common-mistakes)
* [Checklist](#checklist)
* [Quiz de autoevaluación](#self-test-quiz)

---

<a id="symptoms"></a>
## Síntomas de un controlador gordo y un modelo gordo

El problema no es el número de líneas, sino que en un mismo sitio se mezclan distintos motivos de cambio. Señales de que ha llegado el momento de repartir el código:

* **La lógica se copia entre puntos de entrada.** El controlador web, el controlador de la API y el comando de consola hacen «casi lo mismo».
* **Probar un escenario exige una petición HTTP.** Para comprobar el cálculo de un descuento hay que iniciar sesión, montar el formulario e interpretar la redirección.
* **No está claro dónde está el límite de la transacción.** El pedido se ha guardado, pero la reserva de stock ha fallado, y en la base de datos queda un pedido sin reserva.
* **Los efectos secundarios ocurren antes del commit.** El correo «pedido realizado» ya se ha enviado, pero la transacción se ha revertido.
* **El modelo lo sabe todo.** `Order` envía correos, llama al CRM y calcula impuestos. Cualquier tarea exige editar el mismo archivo, y los conflictos en Git se vuelven lo normal.
* **Arrays de forma desconocida.** ¿`$data['items'][0]['qty']` o `$data['items'][0]['quantity']`? La respuesta solo está en el depurador.

---

<a id="layers"></a>
## Mapa de capas: quién se encarga de qué

| Capa | Se encarga de | No debe |
|------|-------------|-----------|
| **Controller** | Recibir la petición HTTP, invocar el escenario y devolver la respuesta | Calcular precios ni llamar a APIs externas |
| **FormRequest** | La autorización y la validación de la entrada, la construcción del DTO | Guardar datos |
| **DTO** | Transportar datos tipados entre capas | Contener lógica de negocio ni acceder a la BD |
| **Action** | Un escenario de negocio, los límites de la transacción, el orden de los pasos | Saber nada de HTTP, la sesión ni las redirecciones |
| **Service** | Una operación o integración reutilizable (precios, pagos, almacén) | Dirigir el escenario completo |
| **Model** | Datos, relaciones, casts, scopes, valores derivados sencillos | Enviar correos ni hacer peticiones HTTP |
| **Job / Event** | Efectos secundarios diferidos y asíncronos | Devolver un resultado a la petición |

La dirección de las dependencias es única: controlador → Action → servicios y modelos. Un servicio nunca llama a una Action, y el modelo no sabe nada ni de unos ni de otros.

---

<a id="dto"></a>
## DTO: un contrato en lugar de un array

Un DTO (Data Transfer Object) es un objeto sin comportamiento que transporta datos a través del límite de una capa. En PHP moderno son unas pocas líneas gracias a las clases readonly ([PHP 8.2](../php/8.2)) y a la promoción de propiedades en el constructor:

```php
// app/Domain/Orders/Data/OrderLineData.php
namespace App\Domain\Orders\Data;

final readonly class OrderLineData
{
    public function __construct(
        public int $productId,
        public int $quantity,
    ) {}
}
```

```php
// app/Domain/Orders/Data/PlaceOrderData.php
namespace App\Domain\Orders\Data;

use App\Domain\Orders\Enums\DeliveryMethod;

final readonly class PlaceOrderData
{
    /**
     * @param  list<OrderLineData>  $lines
     */
    public function __construct(
        public int $customerId,
        public array $lines,
        public DeliveryMethod $delivery,
        public ?string $promoCode = null,
    ) {}
}
```

Lo que aporta frente a un array:

* **Tipos.** `quantity` siempre es `int`; `delivery` es un valor del enum, no la cadena `"courrier"` con una errata.
* **Inmutabilidad.** Ningún paso del escenario puede «retocar en silencio» los datos para el siguiente.
* **Sugerencias del IDE y análisis estático.** PHPStan/Psalm detectan el acceso a un campo inexistente antes de ejecutar nada.
* **Independencia del origen.** La Action no sabe si los datos llegaron de un formulario, de una API JSON, de una importación CSV o de un test.

La validación se queda en el `FormRequest`: es su trabajo. El DTO se construye a partir de datos ya validados. Resulta práctico tener la construcción junto a las reglas:

```php
// app/Http/Requests/PlaceOrderRequest.php
public function rules(): array
{
    return [
        'lines' => ['required', 'array', 'min:1'],
        'lines.*.product_id' => ['required', 'integer', 'exists:products,id'],
        'lines.*.quantity' => ['required', 'integer', 'min:1', 'max:100'],
        'delivery' => ['required', Rule::enum(DeliveryMethod::class)],
        'promo_code' => ['nullable', 'string', 'max:32'],
    ];
}

public function toData(): PlaceOrderData
{
    $validated = $this->validated();

    return new PlaceOrderData(
        customerId: $this->user()->id,
        lines: array_map(
            fn (array $line) => new OrderLineData((int) $line['product_id'], (int) $line['quantity']),
            $validated['lines'],
        ),
        delivery: DeliveryMethod::from($validated['delivery']),
        promoCode: $validated['promo_code'] ?? null,
    );
}
```

> [!NOTE]
> **¿Hace falta un paquete?** Para una decena de DTO bastan clases readonly escritas a mano. Paquetes como `spatie/laravel-data` añaden construcción automática a partir de la petición, validación mediante atributos y transformación a JSON. Es cómodo cuando hay cientos de DTO y además sirven como recursos de la API, pero ata la capa de dominio al paquete. Empiece con clases sencillas.

Guarde el dinero en los DTO como un entero en unidades mínimas (céntimos, centavos) o como un objeto `Money`, no como `float`: en PHP `0.1 + 0.2` no es igual a `0.3`.

---

<a id="actions"></a>
## Clases Action: un escenario, una clase

Una Action es una clase con un único método público que ejecuta un escenario de negocio de principio a fin. Su nombre es un verbo del lenguaje del dominio: `PlaceOrder`, `CancelSubscription`, `InviteTeamMember`.

```php
// app/Domain/Orders/Actions/PlaceOrder.php
namespace App\Domain\Orders\Actions;

use App\Domain\Orders\Data\PlaceOrderData;
use App\Domain\Orders\Events\OrderPlaced;
use App\Domain\Orders\Services\PriceCalculator;
use App\Domain\Orders\Services\StockReservations;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

final class PlaceOrder
{
    public function __construct(
        private PriceCalculator $prices,
        private StockReservations $stock,
    ) {}

    public function handle(PlaceOrderData $data): Order
    {
        return DB::transaction(function () use ($data): Order {
            $quote = $this->prices->quote($data->lines, $data->promoCode);

            $order = Order::create([
                'customer_id' => $data->customerId,
                'delivery' => $data->delivery,
                'total_cents' => $quote->totalCents,
                'discount_cents' => $quote->discountCents,
            ]);

            $order->lines()->createMany($quote->linesForStorage());

            // Throws when stock is insufficient, which rolls back the whole order.
            $this->stock->reserve($order);

            // The event implements ShouldDispatchAfterCommit,
            // so listeners never see an order that was rolled back.
            OrderPlaced::dispatch($order->id);

            return $order;
        });
    }
}
```

Las decisiones clave de este código:

* **El límite de la transacción está en la Action.** El escenario sabe qué pasos deben ejecutarse de forma atómica. El controlador no se ocupa de eso, y los servicios tampoco.
* **Dependencias a través del constructor.** El contenedor de Laravel construye `PriceCalculator` y `StockReservations` por sí mismo, y en un test es fácil sustituirlos.
* **Efectos secundarios después del commit.** Un evento con la interfaz `ShouldDispatchAfterCommit` se despacha solo después de confirmar la transacción. Para los jobs de cola hace lo mismo `ShouldQueueAfterCommit` o la opción `after_commit` de la conexión.
* **Se devuelve un resultado, no una respuesta HTTP.** La Action no sabe qué viene después: una redirección, JSON o una línea en la consola.

```php
// app/Domain/Orders/Events/OrderPlaced.php
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;

final class OrderPlaced implements ShouldDispatchAfterCommit
{
    use Dispatchable;

    public function __construct(public int $orderId) {}
}
```

Cómo llamar al método —`handle()`, `execute()` o `__invoke()`— es cuestión de acuerdo dentro del equipo. Lo importante es que sea uno solo y el mismo en todo el proyecto. `__invoke()` permite pasar la Action como callable; `handle()` se lee mejor en una llamada explícita.

> [!NOTE]
> **Una Action puede llamar a otra Action** si forma parte del escenario: `PlaceOrder` puede llamar a `ApplyLoyaltyPoints`. Pero vigile las transacciones: un `DB::transaction()` anidado crea un savepoint, y revertir la interna no revierte la externa si la excepción se captura.

---

<a id="services"></a>
## Capa de servicios: cuándo hace falta de verdad

Un servicio es una clase en torno a una única responsabilidad que utilizan varios escenarios: cálculo de precios, gestión del almacén, pasarela de pago, generación de PDF. No sabe para qué se le ha llamado.

```php
// app/Domain/Payments/Contracts/PaymentGateway.php
interface PaymentGateway
{
    public function charge(int $amountCents, string $currency, string $idempotencyKey): PaymentResult;

    public function refund(string $paymentId, int $amountCents): RefundResult;
}
```

```php
// app/Providers/AppServiceProvider.php
public function register(): void
{
    $this->app->bind(PaymentGateway::class, StripePaymentGateway::class);
}
```

Una interfaz está justificada cuando hay más de una implementación (la pasarela real y un fake para los tests, dos proveedores en países distintos) o cuando marca el límite con el mundo exterior. Para un `PriceCalculator` interno con una única implementación, la interfaz es un archivo de más: una clase concreta se sustituye en un test con la misma facilidad mediante `$this->mock()` o `$this->app->instance()`.

El mayor peligro de la capa de servicios es el **God Service**. Un `OrderService` con los métodos `create`, `cancel`, `refund`, `export`, `notify`, `recalculate` y cuarenta dependencias en el constructor es el mismo controlador gordo, solo que en otra carpeta. Si un servicio tiene más de cinco a siete métodos públicos que no comparten datos, en realidad son varias clases Action pegadas en una.

---

<a id="thin-controller"></a>
## El controlador después de la refactorización

```php
// app/Http/Controllers/OrderController.php
public function store(PlaceOrderRequest $request, PlaceOrder $placeOrder): RedirectResponse
{
    try {
        $order = $placeOrder->handle($request->toData());
    } catch (InsufficientStock $e) {
        return back()->withErrors(['lines' => $e->getMessage()])->withInput();
    }

    return to_route('orders.show', $order);
}
```

El controlador traduce HTTP a una llamada al escenario, y el resultado del escenario, de vuelta a HTTP. La excepción de dominio `InsufficientStock` se convierte en un error del formulario. En el controlador de la API, la misma excepción se convertirá en una respuesta `422`, y la lógica del pedido no cambia.

---

<a id="reuse"></a>
## Una lógica, muchas entradas: comandos, jobs, tests

La misma Action, sin cambios, se invoca desde el comando de consola de importación:

```php
// app/Console/Commands/ImportMarketplaceOrders.php
public function handle(MarketplaceClient $client, PlaceOrder $placeOrder): int
{
    foreach ($client->newOrders() as $external) {
        $placeOrder->handle(MarketplaceOrderMapper::toData($external));
    }

    return self::SUCCESS;
}
```

Y se prueba con un test sin HTTP, sin sesiones y sin CSRF:

```php
public function test_placing_an_order_reserves_stock(): void
{
    $customer = User::factory()->create();
    $product = Product::factory()->create(['stock' => 10, 'price_cents' => 1500]);

    $order = app(PlaceOrder::class)->handle(new PlaceOrderData(
        customerId: $customer->id,
        lines: [new OrderLineData($product->id, 2)],
        delivery: DeliveryMethod::Courier,
    ));

    $this->assertSame(3000, $order->total_cents);
    $this->assertSame(8, $product->fresh()->stock);
}

public function test_order_is_rolled_back_when_stock_is_insufficient(): void
{
    $product = Product::factory()->create(['stock' => 1]);

    $this->expectException(InsufficientStock::class);

    try {
        app(PlaceOrder::class)->handle(new PlaceOrderData(
            customerId: User::factory()->create()->id,
            lines: [new OrderLineData($product->id, 5)],
            delivery: DeliveryMethod::Pickup,
        ));
    } finally {
        $this->assertDatabaseCount('orders', 0);
    }
}
```

A partir de ahí, los tests del controlador se vuelven finos: comprueban la validación, la autorización y que la respuesta sea la correcta. Las reglas de negocio se comprueban una sola vez, a nivel de Action.

---

<a id="models"></a>
## Qué se queda en el modelo

El objetivo no es un «modelo anémico» sin un solo método. Un modelo Eloquent es un buen lugar para todo lo que describe **los propios datos**:

* relaciones (`lines()`, `customer()`), casts (`'delivery' => DeliveryMethod::class`), scopes (`scopePaid()`);
* valores derivados sencillos sin efectos secundarios: `isPaid()`, `canBeCancelledBy(User $user)`, el accessor `total` a partir de `total_cents`;
* invariantes de una sola entidad que no requieren otros servicios.

Lo que no debe haber en el modelo: envío de correos, peticiones HTTP, trabajo con colas, lógica compleja en observers. Los observers son especialmente traicioneros: se disparan también en importaciones, en seeders y en tests, y desde el código del escenario no se ven. Si un efecto secundario forma parte del escenario, su lugar está en la Action.

---

<a id="modules"></a>
## Límites entre módulos: DTO entre contextos

Cuando el proyecto crece, conviene agrupar el código no por capas técnicas (`Controllers`, `Models`, `Services`), sino por áreas de dominio:

```
app/
├── Domain/
│   ├── Orders/
│   │   ├── Actions/        PlaceOrder, CancelOrder
│   │   ├── Data/           PlaceOrderData, OrderLineData
│   │   ├── Enums/          DeliveryMethod, OrderStatus
│   │   ├── Events/         OrderPlaced
│   │   └── Services/       PriceCalculator, StockReservations
│   ├── Billing/
│   │   ├── Actions/        ChargeOrder, IssueRefund
│   │   ├── Contracts/      PaymentGateway
│   │   └── Data/           PaymentResult
│   └── Catalog/
├── Http/                   controllers and form requests stay framework-shaped
└── Models/
```

La regla para los límites: **el módulo `Billing` no acepta el modelo `Order` del módulo `Orders`.** Recibe un DTO o un identificador:

```php
// Inside the Orders module: a listener translates the event into a Billing call.
final class ChargePlacedOrder implements ShouldQueue
{
    public function handle(OrderPlaced $event, ChargeOrder $charge, OrderSummaryQuery $orders): void
    {
        $summary = $orders->summary($event->orderId); // returns a DTO, not a model

        $charge->handle(new ChargeRequestData(
            reference: "order-{$summary->id}",
            amountCents: $summary->totalCents,
            currency: $summary->currency,
        ));
    }
}
```

Por qué no el modelo:

* el modelo permite llamar a `$order->update()` desde un módulo ajeno, y nadie sabrá quién cambió el estado;
* las relaciones lazy convierten el acceso a `$order->customer->address` en consultas de las que el módulo `Billing` no tiene ni idea;
* el esquema de la tabla `orders` se convierte en una API pública: no se puede cambiar sin revisar todos los módulos.

Es el mismo principio que en la [transición del monolito a los microservicios](monolith-to-microservices-architecture): primero, límites explícitos dentro de una misma aplicación, y solo después, a través de la red, si es que llega a hacer falta.

---

<a id="when-not"></a>
## Cuándo es sobreingeniería

Las capas cuestan tiempo: más archivos, más saltos por el código, más acuerdos. No compensan si:

* **Es un CRUD sin reglas.** Un formulario de edición de perfil que valida y guarda cinco campos no necesita `UpdateProfileAction` ni `UpdateProfileData`. `$user->update($request->validated())` es código perfectamente válido.
* **Es un panel de administración en Filament o Nova.** El framework ya impone su propia estructura, y las clases Action encima de ella a menudo duplican sus recursos.
* **Es un prototipo.** Mientras busca el producto, la velocidad de cambio importa más que la arquitectura. La refactorización en capas llega cuando el escenario se ha estabilizado.
* **El escenario tiene un único punto de entrada y ningún efecto secundario.** No hay nada que reutilizar ni nada que aislar.

Una buena señal de que ha llegado el momento: una segunda entrada al mismo escenario, el primer «el correo se envió, pero la transacción se revirtió» o el primer test imposible de escribir sin HTTP.

---

<a id="migration"></a>
## Cómo refactorizar de forma gradual

Reescribir todo el proyecto «a Actions» no hace falta y es peligroso. Un camino que funciona:

1. **Los escenarios nuevos, directamente en el nuevo estilo.** Esto marca la pauta sin riesgo para el código antiguo.
2. **Antes de extraer, un test de caracterización.** Se escribe un feature test sobre el comportamiento actual del controlador, aunque sea extraño. Detecta regresiones durante el traslado.
3. **Extraiga un escenario cada vez.** El cuerpo del método del controlador se mueve al `handle()` de la Action casi sin cambios, y el controlador pasa a llamarla. El test debe seguir en verde.
4. **Solo después, mejore.** Introduzca DTO en lugar de arrays, mueva los efectos secundarios a después del commit, extraiga servicios de los fragmentos repetidos.
5. **Elimine las copias.** El segundo y el tercer controlador pasan a llamar a la misma Action, y los duplicados desaparecen.

Cada paso es un PR pequeño e independiente que se puede revertir.

---

<a id="common-mistakes"></a>
## Errores frecuentes

**1. La Action recibe un `Request`.**
El escenario vuelve a quedar atado a HTTP, y no se puede invocar desde un comando o un job sin una petición falsa. Pase un DTO.

**2. DTO con lógica de negocio.**
Un método `calculateTotal()` en un DTO es un servicio escondido dentro de un objeto de datos. El DTO solo transporta datos.

**3. DTO mutables.**
Las propiedades públicas que no son readonly permiten que un paso cambie los datos para otro. Use `readonly`.

**4. Un God Service en lugar de un controlador gordo.**
Un `OrderService` de dos mil líneas es el mismo problema en una carpeta nueva. Los escenarios, a clases Action; los servicios, acotados.

**5. Efectos secundarios dentro de la transacción.**
Los correos, webhooks y jobs de cola despachados antes del commit se ejecutan incluso si hay rollback. Use `ShouldDispatchAfterCommit`, `ShouldQueueAfterCommit` o `afterCommit()`.

**6. Una interfaz para cada clase.**
Una `PriceCalculatorInterface` con una única implementación y sin límite externo es un nivel de indirección de más. Laravel sabe sustituir clases concretas en los tests.

**7. Capas por tener capas.**
Una `UpdateUserNameAction` con `UpdateUserNameData` para una sola línea de `$user->update()` es ceremonia, no arquitectura.

---

<a id="checklist"></a>
## Checklist

1. El controlador solo traduce HTTP a una llamada al escenario y el resultado, de vuelta a HTTP.
2. La validación y la autorización están en el `FormRequest`, y ahí mismo se construye el DTO.
3. Un escenario de negocio, una clase Action con un único método público.
4. El límite de la transacción se define en la Action.
5. Los efectos secundarios (correos, webhooks, jobs) se despachan después del commit.
6. Los DTO son `final readonly`, con tipos y sin lógica; el dinero no va en `float`.
7. Los servicios son acotados; las interfaces, solo para límites externos y varias implementaciones.
8. Entre módulos se pasan DTO o identificadores, no modelos.
9. Las reglas de negocio están cubiertas por tests a nivel de Action, sin HTTP.

---

## Resumen

Las clases Action, los servicios y los DTO no tienen que ver con carpetas bonitas, sino con que cada escenario de negocio tenga un único lugar, un único límite de transacción y un único conjunto de tests. Empiece por el dolor —duplicación, efectos secundarios antes del commit, imposibilidad de probar— e introduzca exactamente tantas capas como hagan falta para eliminarlo. Que el CRUD sencillo siga siendo sencillo.

---

<a id="self-test-quiz"></a>
## Cuestionario de autoevaluación

### Pregunta 1: ¿Dónde debe definirse el límite de la transacción al realizar un pedido?
- A) En el controlador, alrededor de la llamada a la Action.
- B) En la clase Action, que sabe qué pasos del escenario deben ejecutarse de forma atómica.
- C) En cada servicio por separado: una transacción propia en cada llamada.

<details>
<summary><b>Mostrar respuesta</b></summary>

**Respuesta: B**
El escenario sabe que el pedido, sus líneas y la reserva de stock deben guardarse juntos. El controlador no debe conocer esos detalles, y las transacciones separadas en los servicios no garantizan la atomicidad del escenario completo.
</details>

### Pregunta 2: Dentro de `DB::transaction()` se envía el correo «Pedido realizado» y luego la reserva de stock falla con una excepción. ¿Qué ocurre sin medidas adicionales?
- A) El correo no se envía, porque la transacción se ha revertido.
- B) El correo se envía (o el job de envío entra en la cola), aunque el pedido no exista en la base de datos.
- C) Laravel cancela el correo automáticamente mediante el evento de rollback.

<details>
<summary><b>Mostrar respuesta</b></summary>

**Respuesta: B**
El rollback de la transacción solo afecta a la base de datos. Los efectos secundarios externos deben despacharse después del commit: mediante `ShouldDispatchAfterCommit` en los eventos, y `ShouldQueueAfterCommit` o `afterCommit()` en los jobs.
</details>

### Pregunta 3: El módulo `Billing` tiene que cobrar un pedido. ¿Qué es mejor pasarle desde el módulo `Orders`?
- A) El modelo `Order` completo, para que `Billing` cargue por sí mismo las relaciones que necesite.
- B) Un DTO con el importe, la moneda y la referencia al pedido, o el identificador del pedido.
- C) El array `$request->all()` de la petición original.

<details>
<summary><b>Mostrar respuesta</b></summary>

**Respuesta: B**
El DTO fija qué datos necesita el módulo y no le permite modificar registros ajenos ni lanzar consultas ocultas a través de las relaciones. El esquema de la tabla `orders` deja de ser una API pública para los demás módulos.
</details>