Eight Lessons From a Nine-Month Framework Migration

From PHP 5.6 and MySQL to Symfony 8 and PostgreSQL — a production migration without a big-bang rewrite. Eight lessons from 488 commits, 4 bridge components, and one strangler fig that actually worked.

A stack of legacy server and database icons transforming along a dashed arc into a modern cloud-based stack

A production HR and Payroll application built on Zend Framework 1 (PHP 5.6, MySQL) needed to become a Symfony 8 platform (PHP 8.4, PostgreSQL). The framework had been end-of-life since 2016. A big-bang rewrite was too risky. The application handled payroll for hundreds of employees across multiple countries.

We chose the Strangler Fig pattern: run both frameworks side by side, incrementally port modules from ZF1 to Symfony, and remove the bridge when nothing legacy remained.

The migration spanned roughly nine months from the first containerized setup to the final bridge removal, with the core work led by a single developer alongside sporadic contributor commits. It left us with 488 commits, four bridge components, and over 12,000 lines of deleted bridge code. Here are the eight lessons worth sharing.


1. The Strangler Fig Router does not need to be a router

Most descriptions of the Strangler Fig pattern assume you need a proxy, a reverse proxy, or a dedicated routing service that decides at the HTTP level where each request should go. We started with exactly that approach and realized it was over-engineering.

What we did instead: Symfony's native routing became the Strangler Fig Router. Every HTTP request hits the Symfony kernel. Routes that have been migrated resolve to Symfony controllers. Routes that have not been migrated fall through to a catch-all controller that boots ZF1 in process.

The first version of this was a few lines of inline code in public/index.php:

php
// Attempt Symfony first
$response = $kernel->handle($request);
if ($response->getStatusCode() !== 404) {
    $response->send(); exit;
}
// 404? Boot ZF1 and render instead
$application = new Zend_Application(APPLICATION_ENV, 'application.ini');
$application->bootstrap()->run();

We later moved this into a proper Symfony controller with a catch-all route at the lowest possible priority:

php
#[Route('/{url}', name: 'legacy_fallback', requirements: [
    'url' => '.*',
], defaults: ['url' => ''], priority: -1000)]

The controller boots Zend_Application, dispatches the request through ZF1's front controller, captures the response, and returns it as a Symfony Response object, headers and all.

Why this matters: No reverse proxy. No nginx configuration changes. No separate deployment for the bridge. The same index.php served both frameworks throughout the migration.


2. One database, two ORMs: no ETL required

The most common question we heard was: "How do you split the database during migration?" The answer: you do not split it. Both ORMs query the same PostgreSQL database.

A Symfony event subscriber, the LegacyBridgeListener, runs on every request, creates a Zend_Db adapter from Doctrine's connection parameters, and sets it as the default adapter for Zend_Db_Table models:

php
$conn = $this->entityManager->getConnection();
$params = $conn->getParams();

$db = Zend_Db::factory('Pdo_Pgsql', [
    'host'     => $params['host'],
    'username' => $params['user'],
    'password' => $params['password'],
    'dbname'   => $params['dbname'],
]);
Zend_Db_Table_Abstract::setDefaultAdapter($db);

No ETL, no data duplication, no synchronization scripts. Doctrine entities and Zend_Db_Table models read from and write to the same tables. The same bridge also synchronized the tenant context. Symfony's TenantContext was written into Zend's session namespace so legacy models saw the correct multi-tenant scope.

The pitfall: The listener must guard against sub-requests with isMainRequest(). ESI or {{ render(controller(...)) }} fires a second KernelEvents::REQUEST event on a sub-request. The filter tries to enable the Doctrine tenant filter twice and throws. We hit this one three times.


3. Session-based cross-framework authentication works, but it is fragile

Authentication was the first bridge component we built and the first one we removed. The ZendAuthAuthenticator is a Symfony authenticator that reads Zend_Auth data from the Symfony session:

php
public function supports(Request $request): ?bool
{
    $zendAuth = $request->getSession()->get('Zend_Auth');
    return isset($zendAuth['storage']);
}

If a user was authenticated by ZF1, Symfony recognized them automatically. The session was the shared state.

The fragility: ZF1 stores user data as stdClass by default. If the PHP session serializer changes (different session.serialize_handler or a PHP upgrade), the data becomes an array instead. The authenticator had to handle both:

php
$userId = is_object($zendAuth)
    ? ($zendAuth->id ?? null)
    : ($zendAuth['id'] ?? null);

This was the first component we removed. Authentication was fully migrated early, and the session format assumption was a risk we did not want to carry.


4. ZF1 runs on PHP 8.4 thanks to a community shim

Zend Framework 1 was designed for PHP 5.2 and officially supports up to PHP 7.4. Running ZF1 on PHP 8.4 required two things:

The shim library: shardj/zf1-future (v1.24.4) is a community fork that backports compatibility fixes: dynamic property errors in Zend_Session, Zend_Pdf_Element regressions, and fgetcsv escape parameter changes. This package alone made the migration feasible: without it, every ZF1 page would have crashed on PHP 8.4.

The autoloader bridge: ZF1 uses PSR-0 (Zend_Controller_Action maps to Zend/Controller/Action.php). Symfony uses PSR-4. We registered a Laminas StandardAutoloader before the Symfony kernel booted, alongside ZF1 module autoloaders for each legacy module:

php
$loader = new StandardAutoloader(['autoregister_laminas' => true]);
$loader->register();

foreach (['HR', 'Payroll', 'Base', 'Login'] as $module) {
    new Zend_Application_Module_Autoloader([
        'namespace' => $module,
        'basePath' => APPLICATION_PATH . '/modules/' . $module,
    ]);
}

The consequence: ZF1 generates hundreds of notices on PHP 8.4. We suppressed error reporting for legacy routes to keep pages rendering:

php
error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING & ~E_DEPRECATED);

This is a debugging time bomb. Real errors on legacy routes are invisible in development. Test your legacy pages manually after every PHP version upgrade.


5. The $_unitTestEnabled hack is how you share a session between ZF1 and Symfony

ZF1's Zend_Session::start() calls ini_set() for session settings. If Symfony already started the session (headers already sent), these calls fail silently. The session breaks.

The fix is an internal API flag meant for ZF1's test suite:

php
Zend_Session::$_unitTestEnabled = true;
Zend_Session::start();

This bypasses all ini_set() calls in Zend_Session. It feels wrong, but it works reliably. We carried this across the entire bridge phase, about two months.


6. Port modules by wave, not by feature

We ported the application one ZF1 module at a time. The order was deliberate:

  • Wave 1: Employee directory and Contract management: the most visible pages and the most stable APIs. Getting these right gave the team confidence.
  • Wave 2: Agency, Company, Applicant: supporting modules for the core HR flows.
  • Wave 3: Payroll deep (salary rules, loan types, payslips): the business-critical domain. Ported after the HR core was stable.
  • Wave 4: SaaS services (subscriptions, gating, onboarding): new functionality built directly on Symfony, never existed in ZF1.

Every module followed the same migration pattern: Symfony controller → Doctrine entity → Symfony form → Twig template. The old ZF1 files stayed in place; the catch-all route served them until the Symfony version passed testing. Cleanup happened in batches days or weeks later.

The key metric for knowing when a module was ready: the legacy ZF1 controller for that module stopped receiving requests. We could see this in access logs. Once a ZF1 route returned zero 200s for a week, it was safe to delete.


7. Deleting the bridge was an eight-commit operation over two hours

On May 21, 2026, we deleted the Strangler Fig bridge. The commit message was straightforward: "chore(legacy): remove ZF1 bridge code, library and obsolete Composer packages."

129 files, 12,361 lines deleted. Four bridge components gone:

  • LegacyFallbackController (142 lines)
  • LegacyBridgeListener (65 lines)
  • LegacyAutoloader (46 lines)
  • Entire library/ directory: Custom, ZendX, Menu, DB helpers

The deletion was not a single operation. It was eight coordinated commits:

  1. Remove legacy ZF1 tests
  2. Migrate active utility classes to the modern namespace
  3. Remove the bridge, library, and obsolete Composer packages
  4. Remove orphaned service classes
  5. Fix 49 PHPStan errors exposed by the deletion
  6. Rewrite public/index.php as a standard Symfony front controller
  7. Update phpstan.neon to remove legacy exclusions
  8. Remove the APPLICATION_ENV constant (a ZF1 legacy)

The order matters. Step 2 (migrating utility classes) had to happen before Step 3 (removing them). Step 5 (fixing PHPStan) had to happen before Step 6 (rewriting the front controller). Each step left the application in a working state.


8. What we would do differently

Skip the inline version of the Strangler Fig Router. We spent a week with the fallback logic in index.php before moving it into a controller. The inline version was harder to test and harder to debug. The catch-all route approach worked from day one.

Add isMainRequest() guards to every listener early. The LegacyBridgeListener and the Doctrine tenant filter listener both missed this guard initially. Sub-requests from ESI and Twig's render() caused intermittent failures that were hard to reproduce.

Delete legacy files the same week you port the module. We accumulated ZF1 dead code for weeks. By the time we cleaned it up, nobody remembered which routes were still hitting the legacy versions. The access log metric helped, but immediate cleanup would have been simpler.

Do not underestimate the session serialization format. The stdClass vs. array assumption in ZendAuthAuthenticator was the most fragile piece of the bridge. If we had migrated authentication earlier and moved to JWT, we would have eliminated this risk entirely.


The migration worked because we never tried to do it all at once. Every request was handled by either ZF1 or Symfony. Never a mixed state, never a partial render, never a downtime window. The bridge was temporary by design, and when it came down, it came down cleanly.

This is the overview article of a series. Each lesson here corresponds to a detailed article covering the architecture, code, and decisions behind one aspect of the migration.


— Delaa