Curso de Laravel

Transacciones en Laravel: garantizar que todo se guarde o nada

Por Víctor Peña · Publicado el

Hola, ¿cómo están? Hoy veremos algo que en un CRUD sencillo parece innecesario y en un sistema real es imprescindible.

Imagina que registrar una venta implica tres escrituras: crear la venta, descontar el inventario y registrar el movimiento de caja. Si la segunda funciona y la tercera falla, quedas con un inventario descontado y una caja descuadrada.

Las transacciones resuelven eso: o se hacen las tres, o no se hace ninguna.

¡Empecemos!

El caso sin transacción

public function store(GuardarVentaRequest $request)
{
    $venta = Venta::create($request->validated());

    foreach ($request->items as $item) {
        $venta->detalles()->create($item);

        Producto::find($item['producto_id'])
            ->decrement('stock', $item['cantidad']);
    }

    MovimientoCaja::create([
        'venta_id' => $venta->id,
        'monto' => $venta->total,
    ]);

    return redirect()->route('ventas.show', $venta);
}

Se ve razonable. Y funciona el 99% de las veces.

El problema es el 1%: se cae la conexión, un producto no existe, el disco se llena. La venta queda creada, el inventario descontado a medias y sin movimiento de caja. Y esos datos incoherentes nadie los detecta hasta que alguien cuadra el inventario semanas después.

Con transacción

use Illuminate\Support\Facades\DB;

public function store(GuardarVentaRequest $request)
{
    $venta = DB::transaction(function () use ($request) {
        $venta = Venta::create($request->validated());

        foreach ($request->items as $item) {
            $venta->detalles()->create($item);

            Producto::findOrFail($item['producto_id'])
                ->decrement('stock', $item['cantidad']);
        }

        MovimientoCaja::create([
            'venta_id' => $venta->id,
            'monto' => $venta->total,
        ]);

        return $venta;
    });

    return redirect()->route('ventas.show', $venta);
}

Un DB::transaction() alrededor y ya está.

Si cualquier línea lanza una excepción, se revierte todo automáticamente. La base de datos queda exactamente como estaba antes de empezar.

Y lo que devuelve la función anónima es lo que devuelve DB::transaction(). Por eso el return $venta; de dentro.

Qué hace por debajo

Es el mismo mecanismo que vimos en el curso de MySQL:

START TRANSACTION;
-- las consultas
COMMIT;    -- o ROLLBACK si algo falló

Laravel solo lo envuelve para que no tengas que acordarte del ROLLBACK.

Control manual

Cuando necesitas decidir tú cuándo confirmar:

DB::beginTransaction();

try {
    $venta = Venta::create($datos);

    if ($venta->total > $limite) {
        DB::rollBack();
        return back()->with('error', 'La venta supera el límite autorizado.');
    }

    DB::commit();
} catch (\Throwable $e) {
    DB::rollBack();

    report($e);

    return back()->with('error', 'No se pudo registrar la venta.');
}

Prefiero la versión con función anónima siempre que se pueda: es imposible olvidarse un rollBack(), que es el error más habitual de la versión manual.

La manual tiene sentido cuando quieres revertir por una regla de negocio y no por una excepción, como en ese ejemplo del límite.

El motor de la tabla importa

Un detalle que hace que la gente piense que las transacciones «no funcionan»:

Las tablas MyISAM no admiten transacciones. Si tu tabla usa ese motor, DB::transaction() se ejecuta sin errores y no revierte nada.

SHOW TABLE STATUS WHERE Name = 'ventas';

Debe decir InnoDB. Las migraciones de Laravel lo usan por defecto, así que solo suele pasar en bases de datos heredadas.

Y otra cosa que sorprende: las instrucciones DDL confirman la transacción implícitamente. Un CREATE TABLE o un ALTER TABLE dentro de una transacción hace que lo anterior quede confirmado, aunque después falle todo. Por eso las migraciones no se envuelven en transacciones en MySQL.

Reintentos por bloqueo

Cuando dos transacciones tocan las mismas filas a la vez, la base de datos puede detectar un interbloqueo y abortar una:

DB::transaction(function () {
    // ...
}, 3);

Ese segundo parámetro son los intentos. Si falla por bloqueo, reintenta hasta tres veces antes de rendirse.

Es gratis y evita errores intermitentes que aparecen solo cuando hay varios usuarios trabajando a la vez, que son los más difíciles de reproducir.

Bloquear filas: el caso del stock

Este es el problema clásico y merece verse despacio.

DB::transaction(function () use ($producto, $cantidad) {
    if ($producto->stock < $cantidad) {
        throw new StockInsuficiente;
    }

    $producto->decrement('stock', $cantidad);
});

Parece correcto, pero no lo es. Con dos usuarios comprando el último producto al mismo tiempo:

  1. Usuario A lee stock = 1. Pasa la comprobación.
  2. Usuario B lee stock = 1. Pasa la comprobación.
  3. A descuenta → stock = 0.
  4. B descuenta → stock = -1.

Vendiste algo que no tenías.

La solución es bloquear la fila mientras la lees:

DB::transaction(function () use ($productoId, $cantidad) {
    $producto = Producto::lockForUpdate()->findOrFail($productoId);

    if ($producto->stock < $cantidad) {
        throw new StockInsuficiente;
    }

    $producto->decrement('stock', $cantidad);
});

lockForUpdate() hace que la segunda transacción espere hasta que la primera termine. Cuando B lee, ve stock = 0 y falla correctamente.

lockForUpdate() solo tiene efecto dentro de una transacción. Fuera de una, el bloqueo se libera de inmediato y no sirve de nada.

Existe también sharedLock(), que permite a otros leer pero no modificar. Para el caso del inventario, el que hace falta es lockForUpdate().

Y una advertencia: bloquea lo mínimo y por el menor tiempo posible. Una transacción larga con filas bloqueadas hace esperar a todos los demás.

Lo que no se revierte

Aquí está el error conceptual más importante de esta lección.

Una transacción solo revierte la base de datos. Todo lo demás ya pasó:

DB::transaction(function () use ($request) {
    $venta = Venta::create($request->validated());

    Mail::to($cliente->correo)->send(new VentaConfirmada($venta));   // ⚠️

    $ruta = $request->file('comprobante')->store('ventas', 'public'); // ⚠️

    $this->apiFacturacion->emitir($venta);                            // ⚠️

    $this->descontarInventario($venta);   // esto sí se revierte
});

Si falla el descuento de inventario, la venta se revierte pero el correo ya salió, el archivo sigue en disco y la factura quedó emitida en el sistema externo.

La regla: dentro de la transacción, solo base de datos. Lo demás va después:

$venta = DB::transaction(function () use ($request) {
    $venta = Venta::create($request->validated());
    $this->descontarInventario($venta);
    return $venta;
});

// ya confirmado: ahora sí
Mail::to($cliente->correo)->queue(new VentaConfirmada($venta));

Y si necesitas que algo ocurra justo después de confirmar:

DB::afterCommit(function () use ($venta) {
    ProcesarFactura::dispatch($venta);
});

Los trabajos de cola tienen su propia versión de esto, y lo veremos en la lección de colas: un trabajo despachado dentro de una transacción puede empezar a ejecutarse antes de que la transacción se confirme, y no encontrar el registro que acaba de crearse. Es un fallo desconcertante y bastante común.

// config/queue.php
'after_commit' => true,

Transacciones anidadas

Si un método con transacción llama a otro que también la tiene, Laravel usa puntos de guardado en lugar de abrir una transacción nueva:

DB::transaction(function () {
    $venta = $this->crearVenta();     // este método también usa transaction

    $this->registrarCaja($venta);
});

Funciona correctamente: solo se confirma al cerrarse la más externa.

Y para saber si estás dentro de una:

DB::transactionLevel();   // 0 si no hay ninguna

Dónde ponerlas

En el servicio o la clase de acción, no en el modelo. El modelo no debería decidir el alcance de una operación de negocio.

class RegistrarVenta
{
    public function ejecutar(array $datos): Venta
    {
        return DB::transaction(function () use ($datos) {
            // ...
        });
    }
}

Es la separación de la lección anterior: el controlador coordina, el servicio contiene la operación completa.

Cuándo hacen falta

No todo necesita transacción. La regla es simple:

Si la operación hace más de una escritura y las escrituras dependen entre sí, va en transacción.

  • Crear un paciente → no hace falta.
  • Crear una venta con detalles y movimiento de caja → sí.
  • Actualizar el teléfono → no.
  • Transferir saldo entre dos cuentas → sí, siempre.
  • Eliminar un registro con sus relaciones en cascada → la base de datos ya lo hace atómico.

Errores comunes

  • Varias escrituras relacionadas sin transacción.
  • Correos, archivos o llamadas a APIs dentro de la transacción.
  • rollBack() olvidado en la versión manual.
  • Comprobar el stock sin lockForUpdate().
  • lockForUpdate() fuera de una transacción.
  • Transacciones larguísimas que bloquean filas mientras esperan una API.
  • Despachar trabajos dentro sin after_commit.
  • Tablas MyISAM donde las transacciones no hacen nada.

Para cerrar

Las transacciones son de esas cosas que no se notan cuando están y se notan muchísimo cuando faltan, normalmente meses después, cuando alguien encuentra datos que no cuadran y nadie sabe por qué.

Dos reglas que resumen todo: más de una escritura relacionada, transacción; y dentro de la transacción, solo base de datos.

En la siguiente lección veremos las colas, que son justamente el lugar donde va todo lo que sacamos de la transacción.

Saludos y éxitos.

Norvic Software

Desarrollamos el software que tu empresa necesita

Somos una fábrica de software en Bolivia. Construimos sistemas a medida y aplicaciones móviles, y llevamos Inteligencia Artificial a las empresas que ya tienen un sistema funcionando.

Solicitar cotizaciónVer todos los servicios

Cotización sin costo · Respuesta directa por WhatsApp