# Eight Lessons From a Nine-Month Framework Migration

Published 2026-07-16. Updated 2026-09-17.
Canonical URL https://log.delaa.dev/blog/2026-07-16-eight-lessons-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.

---

<Image
  src="/images/lessons-learned-migrating-zf1-to-symfony8/hero.png"
  alt="A stack of legacy server and database icons transforming along a dashed arc into a modern cloud-based stack"
  width={2000}
  height={1125}
  className="rounded-lg"
/>

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.

We knew a module was ready by one metric. 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, even though we removed it first. A token-based approach like
JWT instead of session sharing would have sidestepped the serialization
risk entirely, at the cost of more upfront work on both sides of the
bridge.

---

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
