Part 6 of 8 in Strangler Fig migration

Porting ZF1 to Symfony Without Downtime, Module by Module

Seven ZF1 modules, four migration waves, one rule: port the most visible feature first. How the Strangler Fig bridge kept legacy routes alive while each module was rebuilt in Symfony — and how we knew when a module was ready to be deleted.

A row of module cards transitioning one by one from legacy blue to migrated gray along a shared timeline

Once the Strangler Fig bridge was operational, with Symfony routing requests and the catch-all falling back to ZF1, the actual migration work began: porting each ZF1 module to Symfony, one at a time.

This article covers the strategy, the order, the pattern, and the cleanup rules we followed.

The porting pattern

Every module followed the same migration sequence:

  1. Symfony controller: register routes for each action, mark as migrated
  2. Doctrine entity: replace the Zend_Db_Table model
  3. Symfony form type: replace the Zend_Form definition
  4. Twig template: replace the PHTML view
  5. Keep the ZF1 files: the catch-all route still serves them until the Symfony version passes testing
  6. Delete ZF1 files: when the access logs show zero hits on the legacy routes

The Strangler Fig bridge made this possible because step 5 was safe: if the Symfony version had a bug, the ZF1 version was still there. Users never saw a broken page. They either got the new Symfony version or the old ZF1 version, never a broken intermediate state.

The four waves

Wave 1: Core HR (Feb 23, 2026)

Employee directory was the first module ported. It was the most visible page in the application, the page every user saw after login. Porting it first gave the team confidence that the pattern worked.

Contract management followed the same day. Contracts were tightly coupled to employees, so porting them together made sense.

Department management completed the core HR triad.

Payslip and loan management were the first Payroll modules. These were higher risk (financial data) but had stable, well-understood APIs.

All five modules were ported in a single day, eight commits between 17:19 and 17:27.

Wave 2: Supporting modules (Feb 24-27)

With the core HR routes migrated, we tackled the surrounding modules — agency and company management (HR administrative data), applicant management (the recruitment pipeline), user and role management (rebuilt directly on the Symfony security layer), job and unit lookups, and currency and exchange rates for payroll calculations.

These modules were simpler: fewer views, fewer business rules. They were completed in about three days.

Wave 3: Payroll depth (Feb 27 → Mar 14)

The payroll module was the most complex: salary rules, loan types, payslip generation, workshop task evaluation, workflow templates. Each sub-module required porting:

  • Salary rule forms and templates
  • Loan and loan type CRUD
  • Rule evaluator engine (formulas, fixed amounts, percentages)
  • PDF payslip generation
  • Workflow templates for recruitment stages

This wave took the longest, about two weeks, because each sub-module had unique business logic that had to be re-implemented correctly.

Wave 4: SaaS and platform features (Apr → May)

The final wave added functionality that had never existed in ZF1:

  • Subscription tiers and quota gating
  • Config bundle import
  • Prorated wage calculation for leased employees
  • Paycheck advance module
  • Managed onboarding setup

These were built directly on Symfony. No ZF1 equivalent existed. The bridge was not involved; new Symfony controllers and entities were added directly.

How we knew when to delete legacy code

The kill metric was simple: access log analysis. The Strangler Fig bridge logged which framework handled each request. If a ZF1 route returned zero 200 responses for seven consecutive days, its corresponding Symfony module was considered stable barring regression tests.

We deleted legacy files in batches, not individually. A typical cleanup session removed 10-20 ZF1 controller files, their associated forms and views, and the corresponding Zend_Db_Table models. The bridge handled this cleanly because the catch-all route only fires for routes that Symfony does not recognize. If the Symfony route is registered and working, the ZF1 controller becomes dead code immediately.

The numbers

WaveDurationModulesZF1 controllers deleted
1: Core HR1 day5~35
2: Supporting3 days8~40
3: Payroll depth2 weeks12~60
4: SaaS3 weeks60 (new functionality)

The total migration of all ZF1 modules took approximately six weeks of active porting effort, spread across two months of calendar time. The remaining three months of the nine-month migration were spent on infrastructure, testing, and the SaaS features that had no legacy counterpart.


The module-by-module approach was slower than a rewrite in terms of total developer hours, and Wave 3 was the proof: the loan rule evaluator alone ate most of those two weeks, because it turned out to encode business logic nobody had documented anywhere. What it bought back was zero downtime and zero rollbacks across six weeks of active porting — every deployed commit left the application in a working state, which made the slower pace an easy trade to defend when someone asked why the migration wasn't done in a weekend.


— Delaa