Concurrencia en Symfony con Doctrine y PostgreSQL: cómo evitar la doble reserva
Symfony y Doctrine requieren un manejo explícito de bloqueos para que las reservas de asientos no se dupliquen cuando varias peticiones llegan simultáneamente.

Dos peticiones intentan reservar el último asiento disponible. Ambas leen el estado disponible antes de que ninguna escriba, lo que provoca que el segundo cliente también obtenga la reserva.
En Symfony, la solución pasa por coordinar el acceso a la base y exponer el estado bloqueado a través de Doctrine. El ejemplo de asientos también funciona para inventario, saldos y transiciones de estado protegidas.
Problema típico
El patrón clásico es: leer el estado, validar la invariante y escribir. En PostgreSQL con READ COMMITTED, el SELECT y el UPDATE se ejecutan en transacciones, pero el segundo SELECT puede ver la misma fila como disponible antes de que el primer UPDATE lo cambie.
Bloqueo pesimista
Doctrine ofrece LockMode::PESSIMISTIC_WRITE, que se traduce en SELECT … FOR UPDATE en PostgreSQL. El bloqueo garantiza que la fila quede reservada hasta que la transacción actual se confirme o revierta.
public function reserve(string $showId, string $seatId, string $reservationId): void {
$this->entityManager->wrapInTransaction(function () use ($showId, $seatId, $reservationId) {
$seat = $this->seatRepository->findForUpdate($showId, $seatId);
if ($seat === null || !$seat->isAvailable()) {
throw new SeatNotAvailableException($seatId);
}
$seat->holdFor($reservationId);
});
}
El método findForUpdate en el repositorio añade el lock y un hint de refresco:
public function findForUpdate(string $showId, string $seatId): ?Seat {
return $this->createQueryBuilder('s')
->andWhere('s.id = :seatId')
->andWhere('IDENTITY(s.show) = :showId')
->setParameter('seatId', $seatId)
->setParameter('showId', $showId)
->getQuery()
->setLockMode(LockMode::PESSIMISTIC_WRITE)
->setHint(Query::HINT_REFRESH, true)
->getOneOrNullResult();
}
Query::HINT_REFRESH evita que Doctrine use una instancia antigua del objeto en la identidad map. Si la fila ya fue cambiada por otra transacción, el objeto se actualiza con el estado real.
Consideraciones de rendimiento
El bloqueo pesimista bloquea la fila hasta que la transacción termina. Si la operación es corta (leer, validar, actualizar) es aceptable; si implica llamadas externas o cálculos largos, se debe mantener el lock solo alrededor de la parte crítica.
Alternativas
- Optimistic locking: usar un campo de versión y lanzar
OptimisticLockExceptioncuando haya conflicto. SELECT … FOR UPDATE SKIP LOCKED: omite filas que ya están bloqueadas, útil en sistemas de cola.ON CONFLICT: evitar duplicados al insertar registros lógicos.
Para más detalles sobre los hints de Doctrine y el soporte de locking, revisa los enlaces de la documentación oficial.
Qué importa
La técnica mostrada es esencial cuando tu aplicación expone APIs concurrentes, ya sea desde HTTP, CLI o mensajería. Con un enfoque correcto, evitas errores de negocio y mantienes la integridad de los datos sin sacrificar demasiado rendimiento.

