{"id":294,"date":"2026-05-07T09:02:03","date_gmt":"2026-05-07T13:02:03","guid":{"rendered":"https:\/\/juredev.com\/blog\/?p=294"},"modified":"2026-05-07T09:38:20","modified_gmt":"2026-05-07T13:38:20","slug":"doctrine-consumo-memoria-unitofwork-y-hydration-rendimiento-como-evitarlo","status":"publish","type":"post","link":"https:\/\/juredev.com\/blog\/2026\/05\/doctrine-consumo-memoria-unitofwork-y-hydration-rendimiento-como-evitarlo\/","title":{"rendered":"Doctrine y el consumo de memoria: C\u00f3mo el UnitOfWork y la hydration pueden arruinar tu rendimiento (y c\u00f3mo evitarlo)"},"content":{"rendered":"\n<p>En el <a href=\"https:\/\/juredev.com\/blog\/2026\/03\/doctrine-a-escala-como-manejar-millones-de-registros\/\">art\u00edculo anterior<\/a> vimos c\u00f3mo sobrevivir cuando tienes que procesar millones de registros con Doctrine: usando <code>flush()<\/code> por bloques, <code>clear()<\/code> y <code>toIterable()<\/code>.<\/p>\n\n\n\n<p>Pero <strong>\u00bfpor qu\u00e9 funcionan realmente esas t\u00e9cnicas?<\/strong> La respuesta est\u00e1 en dos piezas internas de Doctrine que la mayor\u00eda de los desarrolladores subestiman o directamente desconocen: el <strong>UnitOfWork<\/strong> y el proceso de <strong>hydration<\/strong>.<\/p>\n\n\n\n<p>Entender c\u00f3mo funcionan estos componentes no solo te ayudar\u00e1 a optimizar procesos batch, workers o exports. Cambiar\u00e1 la forma en que dise\u00f1as tu c\u00f3digo cuando trabajas con vol\u00famenes grandes de datos.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">El mito: \u00abDoctrine solo ejecuta SQL y devuelve objetos\u00bb<\/h2>\n\n\n\n<p>Muchos desarrolladores piensan que esto:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$user = $repository->find($id);<\/code><\/pre>\n\n\n\n<p>hace b\u00e1sicamente tres cosas:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Ejecuta un <code>SELECT * FROM users WHERE id = ?<\/code>.<\/li>\n\n\n\n<li>Crea un objeto <code>User<\/code>.<\/li>\n\n\n\n<li>Te lo devuelve.<\/li>\n<\/ol>\n\n\n\n<h3 class=\"wp-block-heading\">La realidad es bastante m\u00e1s pesada.<\/h3>\n\n\n\n<p>Cuando Doctrine te devuelve una entidad, no solo crea un objeto simple. Construye y mantiene en memoria todo un ecosistema alrededor de \u00e9l: metadatos, snapshots, proxies, el Identity Map, referencias a colecciones\u2026 Todo eso se queda vivo en memoria hasta que el <code>EntityManager<\/code> se destruye o haces <code>clear()<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Identity Map: el cach\u00e9 oculto (y caro) de Doctrine<\/h2>\n\n\n\n<p>Doctrine tiene una garant\u00eda muy \u00fatil: dentro del mismo <code>EntityManager<\/code>, <strong>la misma fila de la base de datos siempre ser\u00e1 la misma instancia de PHP<\/strong>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$user1 = $repository->find(10);\n$user2 = $repository->find(10);\nvar_dump($user1 === $user2); \/\/ true<\/code><\/pre>\n\n\n\n<p>Esto lo consigue manteniendo un <strong>Identity Map<\/strong> interno: un mapa con todas las entidades cargadas, indexadas por su identificador.<\/p>\n\n\n\n<p><strong>Ventajas<\/strong>: evita duplicados y mantiene la consistencia autom\u00e1ticamente.<\/p>\n\n\n\n<p><strong>Desventaja<\/strong>: cada entidad que cargas ocupa espacio en memoria. Si haces <code>findAll()<\/code> de 10.000 usuarios, tendr\u00e1s 10.000 referencias en ese mapa, aunque solo uses una.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">UnitOfWork: el cerebro (y el mayor consumidor de memoria) de Doctrine<\/h2>\n\n\n\n<p>El <code>UnitOfWork<\/code> (UoW) es el verdadero director de orquesta de Doctrine. Se encarga de:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Rastrear todas las entidades en estado <strong>MANAGED<\/strong><\/li>\n\n\n\n<li>Detectar cambios (comparando con snapshots)<\/li>\n\n\n\n<li>Programar las operaciones de base de datos<\/li>\n\n\n\n<li>Gestionar cascadas y relaciones<\/li>\n<\/ul>\n\n\n\n<p>Puedes verlo as\u00ed:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$uow = $entityManager->getUnitOfWork();\n$managedEntities = $uow->getManagedEntities(); \/\/ Cuidado, esto puede ser enorme<\/code><\/pre>\n\n\n\n<p>El problema es que cuantas m\u00e1s entidades tengas en estado MANAGED, m\u00e1s memoria consume el UnitOfWork y m\u00e1s lento se vuelve cada flush() (porque tiene que revisar todo).<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Los cuatro estados de las entidades (y su impacto real en memoria)<\/h2>\n\n\n\n<p>Solo las entidades <strong>MANAGED<\/strong> pesan en el UnitOfWork. Entender esto es clave.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">NEW<\/h3>\n\n\n\n<p>Entidad reci\u00e9n creada. Doctrine a\u00fan no la administra:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$user = new User(); \/\/ Sin costo en el UoW<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">MANAGED<\/h3>\n\n\n\n<p>Aqu\u00ed empieza el coste real (ya sea por <code>persist()<\/code> o al cargarla desde la BD). Doctrine crea snapshot, la registra en el Identity Map y comienza a trackear cambios.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">REMOVED<\/h3>\n\n\n\n<p>Marcada para borrar. Sigue ocupando memoria hasta que hagas <code>flush()<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">DETACHED<\/h3>\n\n\n\n<p>Doctrine deja de trackearla. Es el estado que buscas en procesos batch para liberar memoria. <code>clear()<\/code> y <code>detach()<\/code> son las herramientas principales para pasar de MANAGED a DETACHED.<\/p>\n\n\n\n<p><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Hydration: el cuello de botella que casi nadie mide<\/h2>\n\n\n\n<p>La mayor\u00eda mide el rendimiento solo por el tiempo del SQL. Gran error.<\/p>\n\n\n\n<p>En un proceso real de importaci\u00f3n con 5.000 entidades y relaciones eager, medimos esto:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><th>Operaci\u00f3n<\/th><th>Tiempo<\/th><\/tr><tr><td>SQL<\/td><td>50ms<\/td><\/tr><tr><td>Hydration<\/td><td>600ms<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p><strong>La hydration<\/strong> es el proceso en el que Doctrine transforma filas crudas en un grafo completo de objetos: crea entidades, asigna valores con conversi\u00f3n de tipos, genera proxies, registra todo en el Identity Map, crea snapshots, inicializa colecciones, etc.<\/p>\n\n\n\n<p>Es mucho m\u00e1s caro de lo que parece, especialmente cuando hay relaciones.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">PDO vs Doctrine ORM: una comparaci\u00f3n justa<\/h2>\n\n\n\n<p>Con <strong>PDO<\/strong>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$rows = $pdo->query($sql)->fetchAll(); \/\/ Arrays planos<\/code><\/pre>\n\n\n\n<p>Bajo consumo de memoria. R\u00e1pido. Sin magia.<\/p>\n\n\n\n<p>Con <strong>Doctrine<\/strong>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$users = $query->getResult(); \/\/ Objetos + grafo completo<\/code><\/pre>\n\n\n\n<p>Tienes todo el poder del ORM\u2026 y tambi\u00e9n todo su coste.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><code>flush()<\/code>: mucho m\u00e1s que ejecutar SQL<\/h2>\n\n\n\n<p>Antes de tocar la base de datos, Doctrine hace:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>computeChangeSets()<\/code> en todas las entidades administradas<\/li>\n\n\n\n<li>Aplicar cascadas<\/li>\n\n\n\n<li>Sincronizar relaciones<\/li>\n\n\n\n<li>Ejecutar listeners (<code>preFlush<\/code>, <code>postFlush<\/code>, etc.)<\/li>\n<\/ul>\n\n\n\n<p>Con 100 entidades es imperceptible. Con 10.000 empieza a notarse. Con cientos de miles puede bloquear tu proceso.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">El patr\u00f3n que destroza la memoria (y que todos hemos escrito)<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>foreach ($rows as $row) {\n    $entity = transform($row);\n    $entityManager->persist($entity); \/\/ \u00a1Acumulando todo!\n}\n$entityManager->flush(); \/\/ Demasiado tarde<\/code><\/pre>\n\n\n\n<p>Cada <code>persist()<\/code> a\u00f1ade la entidad, su snapshot y referencias al UnitOfWork. Al final tienes cientos de miles de objetos en memoria.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">La soluci\u00f3n que s\u00ed funciona: bloques + <code>clear()<\/code><\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>$batchSize = 1000;\n\nforeach ($rows as $i => $row) {\n    $entity = transform($row);\n    $entityManager->persist($entity);\n\n    if (($i % $batchSize) === 0) {\n        $entityManager->flush();\n        $entityManager->clear();   \/\/ \u2190 Esto es clave\n    }\n}\n\n$entityManager->flush(); \/\/ \u00daltimos registros<\/code><\/pre>\n\n\n\n<p><code>clear()<\/code> libera el UnitOfWork e Identity Map completo, pasando todas las entidades a DETACHED. Es la forma m\u00e1s efectiva de controlar la memoria en procesos largos.<\/p>\n\n\n\n<p><strong>Consejo<\/strong>: Empieza con un batch de 500-2000 y aj\u00fastalo seg\u00fan tu entorno.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><code>toIterable()<\/code>: la forma correcta de procesar grandes vol\u00famenes<\/h2>\n\n\n\n<p>En lugar de:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$users = $query->getResult(); \/\/ Todo en memoria<\/code><\/pre>\n\n\n\n<p>Usa:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>foreach ($query->toIterable() as $user) {\n    process($user);\n\n    if (($i % $batchSize) === 0) {\n        $entityManager->clear();\n    }\n}<\/code><\/pre>\n\n\n\n<p>Hidrata de forma progresiva y mantiene un consumo de memoria estable.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">El costo oculto de las relaciones<\/h2>\n\n\n\n<p>Las relaciones complejas amplifican el problema. Por ejemplo:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>User \u2192 Orders \u2192 Items \u2192 Products<\/code><\/pre>\n\n\n\n<p>Un query aparentemente peque\u00f1o puede terminar hidratando:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Miles de objetos.<\/li>\n\n\n\n<li>Proxies.<\/li>\n\n\n\n<li>Colecciones.<\/li>\n\n\n\n<li>Asociaciones internas.<\/li>\n<\/ul>\n\n\n\n<p><strong>El costo de hydration crece de forma explosiva<\/strong>, especialmente con:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Relaciones <code>EAGER<\/code>.<\/li>\n\n\n\n<li>Joins enormes.<\/li>\n\n\n\n<li>Serializaci\u00f3n autom\u00e1tica (API Platform, normalizers).<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">\u00bfCu\u00e1ndo NO deber\u00edas usar Doctrine?<\/h2>\n\n\n\n<p>Doctrine es excelente para:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Modelos de dominio ricos<\/li>\n\n\n\n<li>L\u00f3gica de negocio compleja<\/li>\n\n\n\n<li>Operaciones transaccionales cr\u00edticas<\/li>\n<\/ul>\n\n\n\n<p>Pero <strong>ev\u00edtalo<\/strong> en:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><th>Caso de uso<\/th><th>Alternativa recomendada<\/th><\/tr><tr><td>Analytics \/ Reporting<\/td><td>SQL directo + DTOs<\/td><\/tr><tr><td>ETL \/ Procesos masivos<\/td><td>DBAL o herramientas especializadas<\/td><\/tr><tr><td>Exports grandes<\/td><td>toIterable() + streaming<\/td><\/tr><tr><td>Dashboards \/ Lecturas intensas<\/td><td>Read Models (CQRS) + SQL optimizado<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p><strong>Regla de oro<\/strong>: Si solo est\u00e1s leyendo datos y no necesitas el poder del ORM, no uses Doctrine.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Doctrine es poderoso, pero caro<\/h2>\n\n\n\n<p>Doctrine no consume memoria por capricho. Lo hace porque mantiene un sistema sofisticado de tracking, consistencia y relaciones. El problema surge cuando usamos ese poder en contextos donde no lo necesitamos.<\/p>\n\n\n\n<p><strong>Las t\u00e9cnicas clave<\/strong> para no sufrir son:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Procesar en bloques con <code>flush()<\/code> + <code>clear()<\/code><\/li>\n\n\n\n<li>Usar <code>toIterable()<\/code> en consultas grandes<\/li>\n\n\n\n<li>Evitar <code>findAll()<\/code> y relaciones <code>EAGER<\/code> en operaciones masivas<\/li>\n\n\n\n<li>Elegir la herramienta correcta seg\u00fan el caso de uso (no todo es un clavo para el martillo Doctrine)<\/li>\n<\/ol>\n\n\n\n<p>Doctrine sigue siendo una de las mejores herramientas ORM del ecosistema PHP cuando se usa con criterio. El secreto est\u00e1 en conocer sus costos reales y saber cu\u00e1ndo desactivar su magia.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Preguntas frecuentes<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">\u00bf<code>detach()<\/code> o <code>clear()<\/code>?<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>clear()<\/code>: Limpia todo el EntityManager. Ideal para procesamiento por bloques.<\/li>\n\n\n\n<li><code>detach($entity)<\/code>: Solo libera una entidad espec\u00edfica. \u00datil cuando tienes entidades de referencia que quieres mantener.<\/li>\n<\/ul>\n\n\n\n<p>Usa <code>clear()<\/code> cuando procesas en bloques y no necesitas retener nada entre iteraciones. Usa <code>detach()<\/code> si tienes entidades de referencia (como configuraci\u00f3n o cat\u00e1logos) que quieres mantener activas entre bloques.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">\u00bfC\u00f3mo afecta el lazy loading a la memoria?<\/h3>\n\n\n\n<p>El lazy loading retarda la hidrataci\u00f3n de relaciones hasta que se acceden. Esto puede:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Reducir memoria inicial (si no accedes a las relaciones).<\/li>\n\n\n\n<li>Aumentar memoria inesperadamente (si accedes a muchas relaciones despu\u00e9s).<\/li>\n<\/ul>\n\n\n\n<p>Soluci\u00f3n: Usa <code>EAGER<\/code> solo cuando sea necesario, o carga relaciones manualmente con <code>JOIN FETCH<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">\u00bf<code>toIterable()<\/code> o <code>iterate()<\/code>?<\/h3>\n\n\n\n<p>Ambos permiten procesar filas de forma progresiva, pero <code>toIterable()<\/code> es la opci\u00f3n recomendada porque hidrata entidades una por una y es compatible con <code>clear()<\/code> dentro del bucle. <code>iterate()<\/code>, la API m\u00e1s antigua, tiene limitaciones con algunas configuraciones de Doctrine y est\u00e1 considerada deprecada en versiones recientes. A menos que est\u00e9s en una versi\u00f3n muy antigua del ORM, usa siempre <code>toIterable()<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Que vas a hacer?<\/h2>\n\n\n\n<p>\u00bfHas tenido problemas graves de memoria con Doctrine? \u00bfCu\u00e1nto mejor\u00f3 tu <code>flush()<\/code> despu\u00e9s de implementar bloques y <code>clear()<\/code>? Cu\u00e9ntame en los comentarios. Especialmente me interesan los casos extremos donde estas t\u00e9cnicas no fueron suficientes.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>En el art\u00edculo anterior vimos c\u00f3mo sobrevivir cuando tienes que procesar millones de registros con Doctrine: usando flush() por bloques, clear() y toIterable(). Pero \u00bfpor qu\u00e9 funcionan realmente esas t\u00e9cnicas? La respuesta est\u00e1 en dos piezas internas de Doctrine que la mayor\u00eda de los desarrolladores subestiman o directamente desconocen: el UnitOfWork y el proceso de [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[7],"tags":[15,20],"class_list":["post-294","post","type-post","status-publish","format-standard","hentry","category-desarrollo","tag-php","tag-sql"],"_links":{"self":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/posts\/294","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/comments?post=294"}],"version-history":[{"count":0,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/posts\/294\/revisions"}],"wp:attachment":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/media?parent=294"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/categories?post=294"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/tags?post=294"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}