# Porting ZF1 to Symfony Without Downtime, Module by Module

Published 2026-06-15. Updated 2026-09-17.
Canonical URL https://log.delaa.dev/blog/2026-06-15-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.

---

<Image
  src="/images/module-by-module-zf1-to-symfony-porting/hero.png"
  alt="A row of module cards transitioning one by one from legacy blue to migrated gray along a shared timeline"
  width={2000}
  height={1125}
  className="rounded-lg"
/>

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

| Wave | Duration | Modules | ZF1 controllers deleted |
|---|---|---|---|
| 1: Core HR | 1 day | 5 | ~35 |
| 2: Supporting | 3 days | 8 | ~40 |
| 3: Payroll depth | 2 weeks | 12 | ~60 |
| 4: SaaS | 3 weeks | 6 | 0 (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
