Sharing One PostgreSQL Connection Between Two ORMs
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.

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:
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:
$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 pitfall: isMainRequest()
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