# Sharing One PostgreSQL Connection Between Two ORMs

Published 2026-01-05. Updated 2026-09-17.
Canonical URL https://log.delaa.dev/blog/2026-01-05-two-orms-one-database-zf1-doctrine

> During a Strangler Fig migration, both old and new ORMs must access the same data. Here is how one Symfony event subscriber made Zend_Db and Doctrine ORM share a single database connection. No ETL, no sync scripts, no duplication.

---

<Image
  src="/images/two-orms-one-database-zf1-doctrine/hero.png"
  alt="Two hexagonal ORM icons connected through a shared diamond node to a single database cylinder"
  width={2000}
  height={1125}
  className="rounded-lg"
/>

When you run two frameworks side by side, the database is the hardest
problem. You can split tables, run ETL processes, or maintain sync
scripts. All of these add risk and complexity.

We chose a simpler path. **Both ORMs query the same database.**

## The LegacyBridgeListener

A Symfony event subscriber named `LegacyBridgeListener` ran on every
request at priority 50 (early in the KernelEvents::REQUEST phase). Its
job was to create a Zend_Db adapter from Doctrine's own connection
parameters:

```php
class LegacyBridgeListener
{
    public function onKernelRequest(RequestEvent $event): void
    {
        if (! $event->isMainRequest()) {
            return;
        }

        if (! Zend_Db_Table_Abstract::getDefaultAdapter()) {
            $conn = $this->entityManager->getConnection();
            $params = $conn->getParams();

            $db = Zend_Db::factory('Pdo_Pgsql', [
                'host'     => $params['host'] ?? 'localhost',
                'username' => $params['user'] ?? '',
                'password' => $params['password'] ?? '',
                'dbname'   => $params['dbname'] ?? '',
                'charset'  => 'utf8',
            ]);
            Zend_Db_Table_Abstract::setDefaultAdapter($db);
        }
    }
}
```

**Why it works.** Doctrine's `EntityManager` is already configured with
the database connection parameters (from `doctrine.yaml`). The listener
reads those same parameters and creates a Zend_Db adapter pointing to
the same database. Both ORMs see identical data.

**What we avoided:**
- No ETL pipeline between ZF1 and Symfony
- No table duplication
- No synchronization scripts
- No data migration
- No "split-brain" state where one ORM writes and the other cannot read

## The tenant context bridge

Beyond the database connection, the listener also synchronized the
multi-tenant context. Symfony's `TenantContext` resolved the current
tenant from the HTTP subdomain. For legacy ZF1 models to respect the
same tenant isolation, the listener wrote the tenant ID into Zend's
session namespace:

```php
$tenant = $this->tenantContext->getCurrentTenant();
$auth = new Zend_Session_Namespace('Zend_Auth');
$auth->storage->tenant_id = $tenant->getId();
```

Without this, legacy models would run without tenant scope and
potentially leak data across tenants.

## The isMainRequest() pitfall

The listener guarded against sub-requests with `isMainRequest()`, but
this guard was added after a production incident. Early in the migration,
Twig's `{{ render(controller(...)) }}` triggered a second
`KernelEvents::REQUEST` event from a sub-request. The listener tried to
set the default Zend_Db adapter a second time, which silently failed
(already set, since the adapter was a singleton). But the Doctrine tenant
filter listener, a separate listener, had the same problem and threw
`Filter already enabled`, 500 error every time.

The fix was the `isMainRequest()` check on every listener that touches
shared state. We missed it on three separate listeners before auditing
them all.

## Why this worked

The approach worked because the migration was from MySQL to PostgreSQL
at the same time as the framework migration. When we migrated the
database, we migrated everything. No legacy tables stayed on MySQL.
One database, two ORMs sharing one connection, one set of credentials.

If the database had stayed on MySQL, the approach would have been the
same. Zend_Db has a `Pdo_Mysql` adapter, and Doctrine supports MySQL
equally well. The adapter factory string would change, nothing else.

---

The bridge listener is 65 lines of PHP. It is the single most important
piece of infrastructure in the bridge right now. Without it, ZF1 has
no database at all.

---

— Delaa
