Synapsea
Modernization · Symfony

Migrating Zend Framework 1 to Symfony: method and pitfalls

Zend Framework 1 reached end of life on September 28, 2016. How to move controllers, services, data access, authentication and integrations to Symfony, batch by batch, without taking the application down.

By the Synapsea team · · 4 min read

Zend Framework 1 reached end of life on September 28, 2016; its last public release is 1.12.20. Applications still running on it — and there are many, often at the heart of a business system — carry three risks: an old PHP version imposed by the framework, security fixes that no longer exist, and developers who are increasingly hard to find. Here is how we run the migration to Symfony, and the mistakes we have learned to avoid.

1. Understand the ZF1 application as it is

ZF1 imposes little structure. Before planning anything, map:

  • modules and controllers (application/modules/*/controllers) and the actions actually called (access logs over a few weeks);
  • front controller plugins and action helpers: that is often where authentication, permissions and cross-cutting rules live;
  • models: Zend_Db_Table, hand-written SQL, or a bolted-on ORM (Doctrine 1 is not rare);
  • application resources (Bootstrap.php, application.ini): sessions, cache, database, mail, translation;
  • integrations: SOAP (Zend_Soap), feeds, scheduled exports, third-party services.

The deliverable is a map: which features, which tables, which dependencies between them. It is what the batches are cut from.

2. Prepare the ground: PHP and tests

Two operations come before any line of Symfony.

Run ZF1 on a recent PHP. The original project stops at PHP 7.x; community forks maintain PHP 8 compatibility. That is not a destination, it is a step: it lets the old code and Symfony coexist in the same PHP process.

Write characterization tests. On ZF1 the most effective form is usually a set of HTTP tests (request → expected response, including headers and redirects) on critical journeys, plus a few tests on exports and scheduled jobs. They do not describe what the code should do, but what it does.

3. The façade: ZF1 and Symfony side by side

Install a Symfony skeleton next to the old application and route every request through Symfony's public/index.php. A fallback controller ("legacy bridge") catches the routes Symfony does not know yet and runs the old ZF1 bootstrap:

// src/Controller/LegacyController.php
#[Route('/{path}', requirements: ['path' => '.*'], priority: -100)]
public function __invoke(): Response
{
    ob_start();
    require $this->legacyPath . '/public/index.php'; // ZF1 bootstrap
    $content = ob_get_clean();

    return new Response($content, http_response_code());
}

The bridge needs a few precautions: share the session (same cookie name, same storage), neutralize exit/die calls in the old code, and pass headers through. Once in place, every migrated batch is just one more route declared on the Symfony side.

4. Migrate batch by batch

The order that works best:

  1. Authentication and permissions, first and in one go: Zend_Auth / Zend_Acl become Symfony's Security component. During the transition, a Symfony authenticator reads the ZF1 session so the user stays logged in on both sides.
  2. Read-only screens (lists, detail pages): low risk, a good way to put Doctrine on the existing schema without changing it.
  3. Writes, boundary by boundary, with their validations.
  4. Integrations (SOAP, exports, cron jobs) last, each protected by a tested interface contract.

On the data side, keep the schema as long as both applications write. Doctrine maps onto the existing tables (#[ORM\Table(name: 'legacy_name')] attributes); badly named columns get renamed later, once ZF1 no longer touches them.

5. Pitfalls we have met

  • Rewriting the schema at the same time as the code. Two risky projects in one: impossible to know which one broke. One thing at a time.
  • Forgetting view helpers and plugins. A business rule hidden in a Zend_Controller_Plugin_Abstract disappears silently if it was never listed.
  • Sessions. An incompatible session format between the two sides logs users out at every route switch.
  • exit and header() calls in the old code. They short-circuit the façade; find them (grep, then tests) before meeting them in production.
  • A batch that is too big. The first batch must be small, visible and reversible: it validates the bridge, not the whole strategy.
  • No rollback planned. The façade must be able to send a route back to ZF1 by changing a single line.

6. Finishing: switching ZF1 off

When the fallback controller no longer receives traffic (measure it), remove the old code, migrate the schema (renames, constraints, types), bring dependencies to the target — Symfony 7.4 LTS on PHP 8.4, security-maintained until November 2029 — and delete the bridge. The application is then an ordinary Symfony application, with its test suite and its pipeline.

Takeaways

  • ZF1 has been end-of-life since 2016: the migration is not optional, but it can be progressive.
  • Map, move to a recent PHP, write characterization tests, then start.
  • A Symfony façade delegating to ZF1 lets you migrate route by route without downtime.
  • Authentication first, data schema last.

Our PHP & Symfony modernization offer covers this path in three levels, from preliminary diagnosis to accompanied migration. See also: modernizing a legacy PHP application without a rewrite.

Sources: Zend Framework 1 end-of-life notice (zendframework/zendframework1 repository, "End-of-Life occurred 28 Sep 2016", last release 1.12.20); Symfony documentation (Security component, routing, Doctrine); Symfony release calendar (symfony.com/releases).

A question about this topic in your own context?

A free 30-minute call, written answer within 2 business days.

Let's talk about your project