So, you've inherited a Zend Framework 2 project.

Maybe it was built in 2014. Maybe it's still earning the company real money. Maybe it never got the budget to migrate to Laminas, let alone Symfony or Laravel. And now you're staring at a Module.php, a module.config.php, and a controller that quietly does $this->getServiceLocator()->get('doctrine.entitymanager.orm_default') in the middle of an action.

It works. It's been working. You're the one who has to keep it working.

This article is for you.

We'll walk through how Doctrine actually plugs into Zend Framework 2, not the five-minute tutorial version, but the version where you understand the wiring well enough to refactor it, debug it, and add new features without breaking the rest of the app. EntityManager, repositories, mapping, factories, service wiring, the traps. The whole tour.

If you're starting a brand-new project today, you wouldn't pick ZF2. But that's not the question. The question is: when the codebase already exists, how do you make it pleasant to work in?

Let's break it down.

Why This Combination Still Shows Up

ZF2 was a serious framework. It shipped in 2012, became the long-term-support backbone of a lot of enterprise PHP, and eventually rebranded as Laminas when the project moved to the Linux Foundation in 2019. The ServiceManager, the EventManager, the modular architecture: those ideas were ahead of their time. A lot of teams shipped real systems on it. A lot of those systems are still in production.

Doctrine ORM, meanwhile, won the PHP ORM debate years ago in the "data mapper" camp. It models your domain as plain PHP objects, then handles the round-trip to the database with a tracked Unit of Work. Symfony adopted it as the default. Laminas adopted it as the default. ZF2 didn't ship with it, but you could bolt it on through DoctrineORMModule, and most serious ZF2 codebases did.

So when you open a ZF2 project that talks to a database, the Doctrine layer is almost always there. The question isn't whether to use it. The question is whether the wiring around it is sane.

The DoctrineORMModule Bridge

The connection point between ZF2 and Doctrine is two modules:

  • doctrine/doctrine-module: the generic bridge. Provides the cache, the CLI integration, hydrators, form elements that know how to talk to Doctrine entities.
  • doctrine/doctrine-orm-module: the ORM-specific layer on top. Wires up the EntityManager, the connection, the mapping drivers, the migrations bridge.

You install both and register them in config/application.config.php along with your own modules:

PHP config/application.config.php
return [
    'modules' => [
        'DoctrineModule',
        'DoctrineORMModule',
        'Application',
        'Catalog',
        'Billing',
    ],
    'module_listener_options' => [
        'config_glob_paths' => ['config/autoload/{,*.}{global,local}.php'],
        'module_paths'      => ['./module', './vendor'],
    ],
];

Order matters. DoctrineModule first, then DoctrineORMModule, then anything that depends on them. ZF2 boots modules in order and the ServiceManager only knows about a service after the module that registers it has been loaded.

Once both modules are in the list, DoctrineORMModule registers a small set of services with the ServiceManager. The ones you'll actually use are:

  • doctrine.connection.orm_default: the DBAL connection.
  • doctrine.entitymanager.orm_default: the EntityManager instance.
  • doctrine.driver.orm_default: the metadata driver (annotation, XML, or YAML).
  • doctrine.configuration.orm_default: the Doctrine\ORM\Configuration object.

The _default suffix exists because Doctrine supports multiple connections. If you ever add a second connection, say a read replica or an analytics warehouse, you'd register it as orm_replica or orm_warehouse and pick which one each service uses.

Configuring The Connection And Driver

In ZF2, configuration is just nested arrays. Drop a file in config/autoload/ and the framework merges it into the global config. For Doctrine you'd typically split it: doctrine.global.php for everything that's the same across environments, doctrine.local.php for the credentials.

PHP config/autoload/doctrine.global.php
return [
    'doctrine' => [
        'driver' => [
            'application_entities' => [
                'class' => \Doctrine\ORM\Mapping\Driver\AnnotationDriver::class,
                'cache' => 'array',
                'paths' => [__DIR__ . '/../../module/Application/src/Application/Entity'],
            ],
            'orm_default' => [
                'drivers' => [
                    'Application\Entity' => 'application_entities',
                ],
            ],
        ],
    ],
];
PHP config/autoload/doctrine.local.php
return [
    'doctrine' => [
        'connection' => [
            'orm_default' => [
                'driverClass' => \Doctrine\DBAL\Driver\PDOMySql\Driver::class,
                'params' => [
                    'host'     => getenv('DB_HOST') ?: '127.0.0.1',
                    'port'     => 3306,
                    'user'     => getenv('DB_USER'),
                    'password' => getenv('DB_PASSWORD'),
                    'dbname'   => getenv('DB_NAME'),
                    'charset'  => 'utf8mb4',
                ],
            ],
        ],
    ],
];

The pattern that always trips people up: each module that owns entities registers its own driver under a unique key (application_entities, catalog_entities, billing_entities), and then maps a namespace prefix to that driver under orm_default.drivers. That's how Doctrine knows which folder to scan when it sees an entity from a given namespace.

Get this wrong and you get the famous Class "App\Entity\User" is not a valid entity or mapped super class, which always sounds like a problem with the entity, but is almost always a problem with the driver registration.

How The EntityManager Gets Built

When you ask the ServiceManager for doctrine.entitymanager.orm_default, here's roughly what happens:

Text
ServiceManager
  -> EntityManagerFactory
       -> reads doctrine.configuration.orm_default
            -> reads doctrine.driver.orm_default (combined drivers)
            -> sets cache, proxy dir, naming strategy
       -> reads doctrine.connection.orm_default
            -> opens a PDO connection (lazily, on first query)
       -> Doctrine\ORM\EntityManager::create($conn, $config)
  <- returns a single shared EntityManager

The EntityManager is a shared service. By default the ServiceManager returns the same instance every time you ask for it within a single request. That's important: if two different services both ask for the EM, they get the same Unit of Work, the same identity map, and flush() from one of them commits everything tracked across both.

That's almost always what you want. It's also exactly why long-running CLI scripts get into trouble, but more on that later.

Defining Entities And Loading Mapping

In a ZF2 codebase that's been alive for a while, the entities almost always use annotation mapping. Not because annotations are objectively better than XML or YAML, but because they live next to the class definition and that's what survives ten years of contributors.

PHP module/Catalog/src/Catalog/Entity/Product.php
<?php

namespace Catalog\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass="Catalog\Repository\ProductRepository")
 * @ORM\Table(name="products")
 */
class Product
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=64, unique=true)
     */
    private $sku;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $name;

    /**
     * @ORM\Column(type="integer")
     */
    private $priceCents;

    public function __construct(string $sku, string $name, int $priceCents)
    {
        $this->sku        = $sku;
        $this->name       = $name;
        $this->priceCents = $priceCents;
    }

    public function id(): ?int            { return $this->id; }
    public function sku(): string         { return $this->sku; }
    public function name(): string        { return $this->name; }
    public function priceCents(): int     { return $this->priceCents; }

    public function rename(string $name): void
    {
        $this->name = $name;
    }
}

Two details worth pausing on.

First, repositoryClass. That tells Doctrine to give you a custom repository class when you ask for the repository for Product, instead of the generic Doctrine\ORM\EntityRepository. We'll come back to that.

Second, the constructor takes the values it needs to be valid. There is no public setter for sku and there is no setter for priceCents. Doctrine doesn't care; it uses reflection to read and write private fields directly. Your code stays honest: you can't accidentally create a Product with no SKU.

The mapping driver wired in the previous section knows where to scan for these annotations. Doctrine reads them on first use, builds metadata, caches it, and from then on talks to the database in terms of your entity class names rather than raw table names.

Repositories: The Default One And Yours

If you don't declare a repositoryClass, Doctrine hands you a Doctrine\ORM\EntityRepository when you ask for the repository:

PHP
$products = $entityManager
    ->getRepository(Product::class)
    ->findBy(['sku' => 'ABC-123']);

That works for trivial lookups. It stops being enough the first time you have a query that says more than "find by column equals value." Reach for a custom repository as soon as the query has any business meaning.

PHP module/Catalog/src/Catalog/Repository/ProductRepository.php
<?php

namespace Catalog\Repository;

use Catalog\Entity\Product;
use Doctrine\ORM\EntityRepository;

class ProductRepository extends EntityRepository
{
    /**
     * @return Product[]
     */
    public function findActiveInCategory(int $categoryId, int $limit = 50): array
    {
        return $this->createQueryBuilder('p')
            ->join('p.category', 'c')
            ->where('c.id = :categoryId')
            ->andWhere('p.archivedAt IS NULL')
            ->setParameter('categoryId', $categoryId)
            ->orderBy('p.name', 'ASC')
            ->setMaxResults($limit)
            ->getQuery()
            ->getResult();
    }

    public function findOneBySku(string $sku): ?Product
    {
        return $this->findOneBy(['sku' => $sku]);
    }
}

That findActiveInCategory method does three things at once: it names a domain concept, it hides the join, and it gives the rest of the codebase exactly one place to change when "active" stops meaning "archivedAt is null." That's the value of a custom repository: it's a vocabulary, not a bag of helpers.

The instinct most teams develop over time: keep the controller dumb, keep the service thin, push real query logic into a repository method with a name that reads like English. If your service layer is reaching into the EntityManager and writing DQL inline, that DQL is going to leak into three more places by next quarter.

A note on the older school: in early ZF2 codebases you'll sometimes see a single "table gateway" or "service" class doing both the queries and the business logic. Resist the urge to leave it that way. A repository per aggregate root is enough structure for most applications, and it gives you a clear boundary the next person can find.

Service Wiring: Get The EM In Through The Front Door

This is the section that decides whether your codebase is going to be pleasant or miserable to work on.

ZF2's ServiceManager supports two patterns for getting dependencies into your classes:

  1. Service location: your class holds a reference to the ServiceManager and pulls dependencies out of it on demand.
  2. Constructor injection: your class declares its dependencies as constructor arguments, and a factory wires them when the class is built.

Pattern 1 is what getServiceLocator() enabled in the early days of ZF2. Pattern 2 is what every modern PHP framework does. Use pattern 2 everywhere.

The ZF2 docs themselves moved away from ServiceLocatorAwareInterface around version 2.7, and the trait was deprecated. If you're maintaining a codebase that still uses it, the gradual fix is: every time you touch a controller or service, convert it to factory-based injection. Don't do a big-bang rewrite; do it as you go.

Here's what a clean service factory looks like.

PHP module/Catalog/src/Catalog/Service/Pricing.php
<?php

namespace Catalog\Service;

use Catalog\Repository\ProductRepository;
use Doctrine\ORM\EntityManagerInterface;

class Pricing
{
    private $em;
    private $products;

    public function __construct(EntityManagerInterface $em, ProductRepository $products)
    {
        $this->em       = $em;
        $this->products = $products;
    }

    public function repriceSku(string $sku, int $newPriceCents): void
    {
        $product = $this->products->findOneBySku($sku);

        if ($product === null) {
            throw new \DomainException("Unknown SKU: $sku");
        }

        $product->reprice($newPriceCents);

        $this->em->flush();
    }
}
PHP module/Catalog/src/Catalog/Service/PricingFactory.php
<?php

namespace Catalog\Service;

use Catalog\Entity\Product;
use Interop\Container\ContainerInterface;
use Zend\ServiceManager\Factory\FactoryInterface;

class PricingFactory implements FactoryInterface
{
    public function __invoke(ContainerInterface $container, $name, array $options = null)
    {
        $em = $container->get('doctrine.entitymanager.orm_default');

        return new Pricing(
            $em,
            $em->getRepository(Product::class)
        );
    }
}
PHP module/Catalog/config/module.config.php
return [
    'service_manager' => [
        'factories' => [
            \Catalog\Service\Pricing::class => \Catalog\Service\PricingFactory::class,
        ],
    ],
    'controllers' => [
        'factories' => [
            \Catalog\Controller\PricingController::class
                => \Catalog\Controller\PricingControllerFactory::class,
        ],
    ],
];

Now Pricing declares its real dependencies in its constructor signature. You can read the class and immediately know what it needs. You can unit-test it by passing in a mock EM and a stub repository, with no ServiceManager in sight. And if you ever want to swap one of those dependencies, say wrap the repository in a caching decorator, you change the factory, not the class.

The controller follows the same pattern:

PHP module/Catalog/src/Catalog/Controller/PricingController.php
<?php

namespace Catalog\Controller;

use Catalog\Service\Pricing;
use Zend\Mvc\Controller\AbstractActionController;
use Zend\View\Model\JsonModel;

class PricingController extends AbstractActionController
{
    private $pricing;

    public function __construct(Pricing $pricing)
    {
        $this->pricing = $pricing;
    }

    public function repriceAction()
    {
        $sku   = $this->params()->fromRoute('sku');
        $cents = (int) $this->getRequest()->getPost('price_cents');

        $this->pricing->repriceSku($sku, $cents);

        return new JsonModel(['ok' => true]);
    }
}
PHP module/Catalog/src/Catalog/Controller/PricingControllerFactory.php
<?php

namespace Catalog\Controller;

use Catalog\Service\Pricing;
use Interop\Container\ContainerInterface;
use Zend\ServiceManager\Factory\FactoryInterface;

class PricingControllerFactory implements FactoryInterface
{
    public function __invoke(ContainerInterface $container, $name, array $options = null)
    {
        return new PricingController(
            $container->get(Pricing::class)
        );
    }
}

In ZF2 the ControllerManager is a separate ServiceManager scope, which is why controllers are registered under controllers.factories and not service_manager.factories. The two scopes can talk to each other: the ControllerManager has the main ServiceManager as its parent, so a controller factory can ask for any service.

Architecture diagram showing how DoctrineORMModule wires the EntityManager into ZF2 services: module.config.php configures the chain from ServiceManager through doctrine.entitymanager.orm_default to ProductRepository and the Pricing service.

Once every class in your codebase looks like this, the ServiceManager becomes invisible. You almost never call it directly; it's just a wiring layer at the edges. The body of your application reads like normal PHP that takes its dependencies in the constructor.

A Word On The InvokableFactory Shortcut

For services that have zero dependencies, ZF2's later versions ship Zend\ServiceManager\Factory\InvokableFactory. You can register a class without writing a factory at all:

PHP
'service_manager' => [
    'factories' => [
        \Catalog\Util\SkuGenerator::class => \Zend\ServiceManager\Factory\InvokableFactory::class,
    ],
],

That's fine for value objects and stateless helpers. The moment a class has a real dependency, write a real factory. The five-line factory is documentation: someone reading the module config can see what each service depends on without opening every constructor.

When The EntityManager Closes (And Other Traps)

Doctrine has a small set of failure modes that always ambush teams the first time. Most of them come back to the EntityManager being a long-lived stateful object.

The closed EntityManager. When a query throws an exception inside a transaction, Doctrine closes the EntityManager. Any subsequent operation throws EntityManager is closed. In a normal HTTP request that's fine: the request dies, the next one gets a fresh manager. In a long-running CLI command or a queue worker, it's catastrophic: every job after the first failure also fails, with a misleading error message.

The fix is to either let the worker process die after a failure (let your supervisor restart it), or to detect the closed state and rebuild the manager.

PHP
try {
    $entityManager->getConnection()->beginTransaction();
    $this->processJob($job);
    $entityManager->flush();
    $entityManager->getConnection()->commit();
} catch (\Throwable $e) {
    $entityManager->getConnection()->rollBack();

    if (!$entityManager->isOpen()) {
        // Hand the supervisor a clean exit so it can restart us.
        throw $e;
    }

    throw $e;
}

The growing identity map. The EntityManager remembers every entity it has loaded for the lifetime of the request. That's how change tracking works. In a CLI import that processes a million rows, that lifetime can be hours.

PHP bin/import-products.php
foreach ($rows as $i => $row) {
    $product = new Product($row['sku'], $row['name'], (int) $row['price_cents']);
    $entityManager->persist($product);

    if ($i % 200 === 0) {
        $entityManager->flush();
        $entityManager->clear();
    }
}

$entityManager->flush();
$entityManager->clear();

The clear() call is the thing people miss. Without it, memory climbs forever and the import dies somewhere around the 50,000th row.

Lazy loading and N+1. This isn't ZF2-specific; it's Doctrine. But it shows up in ZF2 controllers all the time because the rendering layer pulls related objects without the developer noticing. If you're rendering a list of orders and printing each customer's name, you need to fetch-join the customer in the repository, not let lazy loading run a separate query for each row.

PHP
public function findRecentForDashboard(int $limit = 50): array
{
    return $this->createQueryBuilder('o')
        ->addSelect('c')
        ->join('o.customer', 'c')
        ->orderBy('o.placedAt', 'DESC')
        ->setMaxResults($limit)
        ->getQuery()
        ->getResult();
}

The addSelect('c') is what tells Doctrine to actually hydrate the customer instead of treating it as a marker for a future query.

Mapping cache in production. In development you can use the array cache and Doctrine re-reads your annotations on every request. In production that's a real cost. Switch the metadata cache and the query cache to a real backend: APCu, Redis, anything that survives across requests:

PHP config/autoload/doctrine.global.php
'doctrine' => [
    'configuration' => [
        'orm_default' => [
            'metadata_cache'    => 'apcu',
            'query_cache'       => 'apcu',
            'result_cache'      => 'array',
            'hydration_cache'   => 'array',
            'generate_proxies'  => false,
            'proxy_dir'         => 'data/DoctrineORMModule/Proxy',
            'proxy_namespace'   => 'DoctrineORMModule\\Proxy',
        ],
    ],
],

Set generate_proxies to false in production and run ./vendor/bin/doctrine-module orm:generate-proxies as part of your deploy. Otherwise every worker is racing to write the same proxy classes to disk on cold start.

A Final Word

ZF2 plus Doctrine has a reputation for being heavy. Some of that reputation is earned: there's a lot of configuration, the modules talk to each other through a service container, and the wiring is not obvious until you've seen it twice.

But once the wiring is in place, the day-to-day code you write looks pretty normal. A service takes an EntityManager and a repository in its constructor. A controller takes a service in its constructor. A repository hides DQL behind a method that reads like English. The framework gets out of the way.

The trick is to take the modern path through the framework even when the codebase didn't start there. No getServiceLocator(). No ServiceLocatorAwareInterface. No service that pulls its dependencies on demand. Every service declares what it needs in its constructor, every dependency is wired by a factory, every entity belongs to a module, every module owns its driver.

Do that, and a ZF2 codebase from 2014 can keep ticking quietly for years to come: predictable, debuggable, and not actively making the next person miserable.

Which, when you're maintaining production software, is most of what matters.