En los artículos anteriores vimos por qué el ORM consume tanta memoria y cómo mitigarlo con flush() por bloques, clear() y toIterable(). Si llegaste hasta aquí, ya estás convencido del problema.
Este artículo es el siguiente paso: cómo salir del ORM conscientemente cuando no lo necesitas, sin abandonar el ecosistema Doctrine ni romper la arquitectura del proyecto.
El punto de partida: ya conoces el costo
No vamos a repetir por qué findAll() puede destruir tu memoria o por qué flush() con 50.000 entidades tarda siglos. Eso ya quedó claro.
La pregunta ahora es concreta:
Tengo una consulta que solo lee datos. No voy a modificar nada. No necesito tracking ni relaciones. ¿Qué hago?
La respuesta es DBAL, y en este artículo vas a ver exactamente cómo usarlo.
Qué es DBAL y qué no es
Doctrine DBAL (Database Abstraction Layer) es la capa que vive por debajo del ORM. Doctrine ORM la usa internamente para ejecutar SQL, pero tú también puedes acceder directamente a ella.
Lo que DBAL te da:
- Conexión a la base de datos con manejo de errores.
- Prepared statements y parameter binding seguro.
- Soporte para transacciones.
- Portabilidad entre motores (MySQL, PostgreSQL, SQLite).
- Integración nativa con Symfony.
Lo que DBAL no hace:
- No hidrata entidades.
- No mantiene Identity Map.
- No genera snapshots.
- No rastrea cambios.
- No gestiona relaciones.
En otras palabras: ejecuta SQL y te devuelve arrays. Sin intermediarios.
Acceder a DBAL desde el EntityManager
Si ya tienes Doctrine ORM en tu proyecto, DBAL está incluido. No necesitas instalar nada extra:
// Desde el EntityManager (cualquier contexto)
$conn = $entityManager->getConnection();
// En Symfony, también puedes inyectarlo directamente
use Doctrine\DBAL\Connection;
class UserReadRepository
{
public function __construct(private readonly Connection $connection) {}
}
Inyectar Connection directamente es preferible en repositorios de lectura: deja claro en la firma que esta clase no usa el ORM.
Tu primera consulta con DBAL
Comparemos el mismo caso con ORM y con DBAL.
Con ORM (para una tabla simple de usuarios):
// Hidrata entidades completas, snapshots, Identity Map...
$users = $this->entityManager
->getRepository(User::class)
->findAll();
Con DBAL:
$users = $this->connection->fetchAllAssociative('
SELECT id, name, email
FROM users
WHERE active = :active
ORDER BY name ASC
', ['active' => true]);
// Resultado: array de arrays asociativos
// [
// ['id' => 1, 'name' => 'Ana García', 'email' => '[email protected]'],
// ['id' => 2, 'name' => 'Luis Martínez', 'email' => '[email protected]'],
// ]
Sin entidades, sin proxies, sin UnitOfWork. Solo los datos que pediste.
Los métodos más útiles de DBAL
DBAL ofrece varios métodos según lo que necesites:
// Múltiples filas → array de arrays asociativos
$rows = $conn->fetchAllAssociative($sql, $params);
// Una sola fila → array asociativo (o false si no existe)
$row = $conn->fetchAssociative($sql, $params);
// Una sola columna de múltiples filas → array plano
$ids = $conn->fetchFirstColumn('SELECT id FROM users WHERE active = 1');
// Un único valor escalar
$count = $conn->fetchOne('SELECT COUNT(*) FROM users');
// Para INSERT, UPDATE, DELETE → devuelve filas afectadas
$affected = $conn->executeStatement(
'UPDATE users SET active = :active WHERE id = :id',
['active' => false, 'id' => 42]
);
DTOs: del array al objeto sin ORM
Los arrays son cómodos para casos simples, pero en una aplicación real querrás objetos tipados. Aquí entran los DTOs (Data Transfer Objects).
Un DTO para lecturas de usuario:
final class UserSummaryDTO
{
public function __construct(
public readonly int $id,
public readonly string $name,
public readonly string $email,
) {}
public static function fromRow(array $row): self
{
return new self(
id: (int) $row['id'],
name: $row['name'],
email: $row['email'],
);
}
}
Y en el repositorio:
public function findActiveSummaries(): array
{
$rows = $this->connection->fetchAllAssociative('
SELECT id, name, email
FROM users
WHERE active = 1
ORDER BY name ASC
');
return array_map(UserSummaryDTO::fromRow(...), $rows);
}
El resultado es un array de objetos tipados, sin hydration de Doctrine, sin tracking, con autocompletado en tu IDE y sin sorpresas de memoria.
Consultas más complejas: donde DBAL realmente brilla
El ORM empieza a crujir cuando las consultas se complican. DBAL, en cambio, escala con naturalidad:
public function getOrderStats(\DateTimeImmutable $from, \DateTimeImmutable $to): array
{
return $this->connection->fetchAllAssociative('
SELECT
DATE(o.created_at) AS day,
COUNT(o.id) AS total_orders,
SUM(o.total) AS revenue,
AVG(o.total) AS avg_order_value,
COUNT(DISTINCT o.user_id) AS unique_customers
FROM orders o
WHERE o.created_at BETWEEN :from AND :to
AND o.status = :status
GROUP BY DATE(o.created_at)
ORDER BY day DESC
', [
'from' => $from->format('Y-m-d'),
'to' => $to->format('Y-m-d'),
'status' => 'completed',
]);
}
Intentar expresar esto con DQL y entidades no solo sería más verboso: sería más lento, más difícil de optimizar y más difícil de leer.
El patrón de repositorio híbrido
Aquí está el núcleo arquitectónico de este artículo.
La idea es simple: un repositorio de escritura con ORM, un repositorio de lectura con DBAL. Los dos coexisten en el mismo proyecto, cada uno especializado en lo que hace bien.
Repositorio de escritura (ORM)
final class UserRepository
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {}
public function save(User $user): void
{
$this->entityManager->persist($user);
$this->entityManager->flush();
}
public function findById(int $id): ?User
{
return $this->entityManager->find(User::class, $id);
}
public function remove(User $user): void
{
$this->entityManager->remove($user);
$this->entityManager->flush();
}
}
Este repositorio trabaja con entidades reales. Tiene tracking, cascadas, eventos del ciclo de vida. Aquí el ORM aporta su máximo valor.
Repositorio de lectura (DBAL)
final class UserReadRepository
{
public function __construct(
private readonly Connection $connection,
) {}
public function findActiveSummaries(): array
{
$rows = $this->connection->fetchAllAssociative('
SELECT id, name, email
FROM users
WHERE active = 1
ORDER BY name ASC
');
return array_map(UserSummaryDTO::fromRow(...), $rows);
}
public function findWithOrderStats(int $userId): ?UserStatsDTO
{
$row = $this->connection->fetchAssociative('
SELECT
u.id,
u.name,
u.email,
COUNT(o.id) AS total_orders,
SUM(o.total) AS total_spent
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.id = :id
GROUP BY u.id
', ['id' => $userId]);
return $row ? UserStatsDTO::fromRow($row) : null;
}
public function countActiveByCountry(): array
{
return $this->connection->fetchAllAssociative('
SELECT country, COUNT(*) AS total
FROM users
WHERE active = 1
GROUP BY country
ORDER BY total DESC
');
}
}
Este repositorio ni siquiera conoce que existe una entidad User. Solo sabe hacer SQL eficiente y devolver datos.
Cómo conviven en un servicio
final class UserService
{
public function __construct(
private readonly UserRepository $users, // ORM: writes
private readonly UserReadRepository $userReads, // DBAL: reads
) {}
// Escritura → ORM
public function register(string $name, string $email): void
{
$user = new User($name, $email);
$this->users->save($user);
}
// Lectura simple → DBAL
public function getActiveSummaries(): array
{
return $this->userReads->findActiveSummaries();
}
// Lectura con lógica de negocio → ORM
public function deactivate(int $userId): void
{
$user = $this->users->findById($userId);
$user->deactivate(); // lógica de dominio en la entidad
$this->users->save($user);
}
}
La regla es clara: si la operación modifica estado o necesita lógica de dominio, usa el repositorio ORM. Si solo lee datos para mostrarlos, usa el repositorio DBAL.
QueryBuilder de DBAL: SQL dinámico sin concatenar strings
Cuando los filtros son dinámicos (búsquedas, paginación, ordenamiento variable), DBAL ofrece un QueryBuilder propio, más ligero que el del ORM:
public function search(UserSearchCriteria $criteria): array
{
$qb = $this->connection->createQueryBuilder()
->select('id', 'name', 'email', 'created_at')
->from('users')
->where('active = :active')
->setParameter('active', true)
->orderBy('name', 'ASC')
->setMaxResults($criteria->limit)
->setFirstResult($criteria->offset);
if ($criteria->country !== null) {
$qb->andWhere('country = :country')
->setParameter('country', $criteria->country);
}
if ($criteria->search !== null) {
$qb->andWhere('name LIKE :search OR email LIKE :search')
->setParameter('search', '%' . $criteria->search . '%');
}
return array_map(
UserSummaryDTO::fromRow(...),
$qb->fetchAllAssociative()
);
}
Sin concatenación de strings, sin riesgo de inyección SQL, sin el overhead del ORM.
Transacciones con DBAL
DBAL también maneja transacciones, algo importante cuando necesitas atomicidad en operaciones de escritura que no pasan por el ORM:
public function transferCredits(int $fromId, int $toId, int $amount): void
{
$this->connection->transactional(function (Connection $conn) use ($fromId, $toId, $amount): void {
$conn->executeStatement(
'UPDATE accounts SET credits = credits - :amount WHERE id = :id',
['amount' => $amount, 'id' => $fromId]
);
$conn->executeStatement(
'UPDATE accounts SET credits = credits + :amount WHERE id = :id',
['amount' => $amount, 'id' => $toId]
);
});
}
Si cualquiera de las dos operaciones falla, la transacción se revierte automáticamente.
Cuándo usar cada herramienta: la guía rápida
| Situación | Herramienta |
|---|---|
| Crear, modificar o eliminar entidades | ORM |
| Lógica de dominio (invariantes, reglas de negocio) | ORM |
| Relaciones complejas con cascadas | ORM |
| Leer datos para una API o listado | DBAL |
| Dashboards, reportes, analytics | DBAL |
| Aggregaciones (COUNT, SUM, AVG) | DBAL |
| Exports masivos | DBAL + toIterable() |
| Consultas con múltiples JOINs sin lógica de dominio | DBAL |
| ETL o procesamiento batch de lectura | DBAL |
Lo que ganamos con esta separación
Para entender el impacto real, veamos qué ocurre al consultar 10.000 usuarios con tres columnas simples (id, name, email) en una tabla sin relaciones. Los números son ilustrativos pero representan proporciones reales que puedes reproducir en cualquier proyecto mediano:
| Métrica | ORM (findAll()) | DBAL (fetchAllAssociative) | Diferencia |
|---|---|---|---|
| Memoria pico | ~85 MB | ~8 MB | ~10× menos |
| Tiempo de ejecución | ~620 ms | ~55 ms | ~11× más rápido |
| Objetos en memoria | ~10.000 entidades + proxies + snapshots | ~10.000 arrays simples | Sin overhead del UoW |
| Trabajo del ORM | Hydration, Identity Map, UnitOfWork, snapshots | Ninguno | — |
La diferencia de memoria se explica por lo que Doctrine construye para cada entidad: la instancia del objeto, una copia snapshot para detectar cambios, la entrada en el Identity Map y, si hay relaciones, proxies adicionales. Con DBAL, una fila es simplemente un array asociativo PHP. Sin más.
El multiplicador empeora con relaciones. Si User tuviera una relación EAGER con Address, el ORM hidrata también 10.000 objetos Address. DBAL solo devuelve lo que el SELECT pide explícitamente.
La diferencia se amplifica en exports y workers. En un proceso que itera 100.000 registros, pasar de ORM a DBAL puede ser la diferencia entre un worker que crashea por memoria y uno que termina sin incidentes.
Más allá de los números:
Legibilidad: el SQL vive donde tiene sentido y es fácil de auditar, optimizar con EXPLAIN, o pasar a un DBA.
Claridad arquitectónica: cuando alguien lee UserReadRepository, sabe inmediatamente que esa clase nunca va a modificar datos. La intención está en la firma.
No es ORM vs SQL, es cada herramienta en su lugar
Doctrine ORM sigue siendo una herramienta excelente para lo que fue diseñada: persistir y recuperar objetos de dominio con estado, relaciones y comportamiento.
El problema nunca fue el ORM. El problema es usarlo para absolutamente todo, incluyendo escenarios donde solo necesitas leer datos y devolverlos.
El patrón híbrido, ORM para escrituras y lógica de dominio, DBAL para lecturas, no requiere sobrearquitectura ni reescribir el proyecto. Puedes introducirlo de forma incremental: la próxima vez que tengas una consulta de lectura costosa, extráela a un repositorio DBAL. Mide la diferencia. Y a partir de ahí decides hasta dónde llevar el patrón.
Escalar una aplicación muchas veces no significa cambiar la base de datos ni añadir caché. Significa dejar de hacer trabajo innecesario antes de llegar a ella.
Preguntas frecuentes
¿Puedo usar DBAL y ORM en la misma transacción?
Sí. Comparten la misma conexión subyacente, por lo que operaciones de ORM y DBAL pueden participar en la misma transacción:
$this->entityManager->beginTransaction();
try {
$this->entityManager->persist($entity);
$this->entityManager->flush();
$this->connection->executeStatement('UPDATE stats SET total = total + 1');
$this->entityManager->commit();
} catch (\Throwable $e) {
$this->entityManager->rollback();
throw $e;
}
¿DBAL protege contra SQL injection?
Sí, siempre que uses parámetros con :nombre o ? y no concatenes valores directamente en el SQL. Los métodos fetchAllAssociative, executeStatement y el QueryBuilder de DBAL usan prepared statements internamente.
¿Necesito instalar algo extra para DBAL?
No. Si tienes doctrine/orm en tu proyecto, doctrine/dbal ya está incluido como dependencia. Solo necesitas inyectar Doctrine\DBAL\Connection en tu clase.
Tema Relacionado: Optimización SQL