Guardaste el pedido, pero los artículos del pedido nunca llegaron a la base de datos. O tal vez el contador de stock bajó, la pasarela de pago dio un error de tiempo de espera (timeout) y ahora el cliente tiene un cargo en su tarjeta pero no tiene un registro del pedido. Estos escenarios suenan como casos límite hasta que dejan de serlo. En una aplicación con mucho tráfico, se convierten en dolores de cabeza diarios.

La causa raíz es casi siempre la misma: una secuencia de escrituras en la base de datos que fueron tratadas como eventos separados e aislados. Cuando uno falla, los demás se quedan atrás. La solución es una transacción de base de datos y, en Laravel, la herramienta es DB::transaction().

Lo que una transacción promete realmente

Una transacción de base de datos vincula múltiples operaciones en una única unidad de trabajo. El motor de la base de datos garantiza que todo lo que esté dentro se confirme (commit) de forma permanente o se revierta (rollback) por completo. No hay puntos medios.

Piensa en una transferencia bancaria. El sistema debe debitar una cuenta y acreditar otra. Si el crédito falla después del débito, el dinero no simplemente se desvanece en el vacío. El banco revierte el débito. Esa reversión es el rollback. Si ambos pasos tienen éxito, la transferencia se confirma, lo que significa que los nuevos saldos se guardan de forma duradera.

Este comportamiento de "todo o nada" es lo que mantiene la consistencia de tus datos. Sin él, los fallos parciales esparcen registros a medio escribir por tus tablas, y esos registros se pudren en tu base de datos porque ningún proceso automatizado sabe cómo limpiarlos de forma segura.

Cómo Laravel realiza la conexión

En SQL puro, escribirías BEGIN, COMMIT y ROLLBACK tú mismo, recordando capturar cada posible error para no dejar nunca una transacción colgando. Laravel envuelve todo ese código repetitivo (boilerplate) en un único método.

Pasas un closure a DB::transaction(). Laravel inicia la transacción, ejecuta tu código y, si el closure termina sin lanzar una excepción, realiza el commit automáticamente. Si algo falla, Laravel captura la excepción, revierte todo (rollback) y vuelve a lanzar el error para que tu registro (logging) y el manejo de errores sigan funcionando como se espera.

use Illuminate\Support\Facades\DB;

DB::transaction(function () {
    $order = Order::create([/* ... */]);
    
    foreach ($cart->items as $item) {
        OrderItem::create([
            'order_id' => $order->id,
            'product_id' => $item->product_id,
            'quantity' => $item->quantity,
        ]);
        
        Product::find($item->product_id)
               ->decrement('stock', $item->quantity);
    }
    
    Payment::create([
        'order_id' => $order->id,
        'amount' => $cart->total,
        'status' => 'completed',
    ]);
});

Si la inserción del pago falla porque falta una clave foránea o la conexión a la base de datos se pierde, el pedido, los artículos del pedido y los cambios de stock se deshacen por completo. No te quedas con un inventario que desapareció sin motivo, ni con un pedido que exige un envío que nunca fue pagado.

Dónde esto te ahorra problemas

Algunos flujos de trabajo dependen absolutamente de la seguridad transaccional. El ejemplo del proceso de pago (checkout) es el más obvio, pero el patrón aparece en todas partes.

Registro de usuarios. Crear una fila de usuario, luego una fila de perfil, luego la configuración por defecto. Si la inserción del perfil falla debido a un caso límite de validación, un usuario sin perfil se convierte en una cuenta fantasma. Cualquier página que asuma que cada usuario tiene un perfil fallará o mostrará una interfaz de usuario rota.

Importaciones masivas. Una carga de CSV que inserta cincuenta registros no debería dejar veinticinco atrás porque la fila veintiséis tenía una fecha mal formada. Envolver el lote en una transacción permite que toda la importación falle como una única unidad limpia. El administrador corrige el archivo y lo intenta de nuevo, en lugar de pasar horas buscando qué filas se filtraron parcialmente.

Inventario y contabilidad. Cada vez que una tabla rastrea un recurso físico y otra rastrea dinero o créditos, ambas deben moverse juntas. Separarlas invita a auditorías que no cuadran y a informes que mienten.

Cuando necesitas el control manual

El enfoque de closure cubre la mayoría de los casos, pero a veces necesitas más control. Una lógica condicional compleja dentro de una clase de servicio, o la necesidad de decidir en tiempo de ejecución si confirmar o no, puede hacer que un único closure resulte incómodo. En esos momentos, puedes gestionar la transacción tú mismo:

DB::beginTransaction();

try {
    // Run your operations
    $order = Order::create([/* ... */]);
    // ... more work ...
    
    if ($someBusinessRulePasses) {
        DB::commit();
    } else {
        DB::rollBack();
    }
} catch (\Throwable $e) {
    DB::rollBack();
    throw $e;
}

Observa el orden dentro del bloque catch: primero haz el rollback, luego lanza la excepción. Si lanzas la excepción antes de hacer el rollback, la transacción permanece abierta en la conexión. Eso puede bloquear filas, causar un deadlock en otras consultas o agotar tu pool de conexiones. Las transacciones manuales son potentes, pero te imponen la carga de la limpieza.

Mantén los efectos secundarios fuera de la transacción

Esta es la regla que suele causar problemas a los equipos en producción. Una transacción solo puede deshacer el trabajo de la base de datos. No puede "desenviar" un correo electrónico, eliminar un archivo de un almacenamiento en la nube o reembolsar un cargo a través de una API de pagos.

Si colocas una llamada a Mail::send() dentro del closure de la transacción, y la base de datos hace un rollback dos líneas después, ese correo electrónico llegará de todos modos a la bandeja de entrada del cliente. El destinatario ahora tiene una factura de un pedido que no existe en tu sistema. Lo mismo se aplica a las notificaciones de Slack, las cargas de archivos a S3 o el envío de webhooks.

La secuencia correcta es:

  1. Completa la transacción y captura cualquier ID o resultado que necesites.
  2. Solo entonces, activa los efectos secundarios externos.

Por ejemplo, encola el correo electrónico de confirmación después del commit, no dentro de él:

$order = DB::transaction(function () {
    // database work only
    return Order::create([/* ... */]);
});

// Side effects happen after the database state is solid
OrderConfirmationJob::dispatch($order);

Hay una segunda razón para mantener las llamadas externas fuera de la transacción: el tiempo. Una transacción mantiene bloqueos y mantiene ocupada una conexión a la base de datos. Esperar tres segundos por una respuesta de la Stripe API mientras se está dentro de una transacción son tres segundos de tiempo de bloqueo innecesario. Mantén la transacción breve y rápida.

Por qué este error se oculta

En una máquina de desarrollo con un único usuario y una base de datos local, las inserciones relacionadas casi siempre tienen éxito. La red es estable, el disco nunca se llena y no hay carga de trabajo competitiva. El código parece correcto porque suele funcionar.

Producción es diferente. Dos clientes envían pedidos en el mismo milisegundo. Un worker de la cola se reinicia a mitad de un trabajo durante un despliegue. Un proveedor de pagos agota el tiempo de espera durante treinta segundos. Sin transacciones, estos eventos crean registros huérfanos y totales desajustados que son difíciles de rastrear. Lo peor es que las pruebas de funcionalidad estándar rara vez los detectan, porque el modo de fallo depende del tiempo y está ligado a la infraestructura, no a simples errores de lógica.

La solución no es exótica. Es mecánica. Ves dos o más escrituras relacionadas y las envuelves. Con el tiempo, debería volverse tan automático como validar una solicitud.

Conviértelo en un reflejo

DB::transaction() no añade complejidad. Elimina la complejidad oculta de intentar limpiar datos parciales a posteriori. Si un grupo de operaciones de base de datos pertenecen entre sí, trátalos de esa manera desde el principio. Tu aplicación se mantendrá íntegra bajo carga, tus registros de errores se mantendrán limpios y tu base de datos no se convertirá en un cementerio de registros a medio terminar.