Running ZF1 Inside Symfony 8 With the Strangler Fig Pattern
The catch-all route that let two frameworks coexist in the same process. How Symfony booted Zend Framework 1 internally, shared the session, and returned a unified response — without a reverse proxy.

The Strangler Fig pattern says you run old and new systems side by side, gradually routing more traffic to the new one until the old one can be removed. Most implementations use a reverse proxy (nginx, envoy, a load balancer) that decides per-request which backend handles it.
Our implementation was different: both frameworks ran in the same process, and Symfony's native router was the Strangler Fig Router.
This article explains how it worked, with the code that made it possible.
The two versions of the router
Version 1: inline in the front controller
The first version was a few lines of PHP in public/index.php. Symfony tried to handle the request. If the response was a 404, the code fell through to ZF1:
// public/index.php — early migration state
$response = $kernel->handle($request);
if ($response->getStatusCode() !== 404) {
$response->send();
$kernel->terminate($request, $response);
exit;
}
// 404 — boot ZF1 and render instead
$library = APPLICATION_PATH . "/../library";
set_include_path(implode(PATH_SEPARATOR, [
realpath($library),
realpath(APPLICATION_PATH . '/../vendor/shardj/zf1-future/library'),
realpath(APPLICATION_PATH . '/modules/License/lib'),
realpath(APPLICATION_PATH . '/modules/Login/lib'),
]));
require_once 'Zend/Application.php';
$loader = new Zend\Loader\StandardAutoloader(['autoregister_zf' => true]);
$loader->register();
$application = new Zend_Application(
APPLICATION_ENV,
APPLICATION_PATH . '/configs/application.ini'
);
$application->bootstrap()->run();
This worked, but it had problems: the inline code was hard to test, hard to debug, and mixing routing logic with bootstrapping violated the single-responsibility principle. It survived for about a week.
Version 2: the catch-all controller
The second version moved the fallback logic into a proper Symfony controller with a catch-all route at the lowest possible priority:
#[Route('/{url}', name: 'legacy_fallback', requirements: [
'url' => '.*',
], defaults: ['url' => ''], priority: -1000)]
The controller boots ZF1 internally, captures the response, and returns it as a Symfony Response object. The key steps:
1. Prepare ZF1 include paths
set_include_path(implode(PATH_SEPARATOR, [
realpath(APPLICATION_PATH . '/../library'),
realpath(APPLICATION_PATH . '/../vendor/shardj/zf1-future/library'),
realpath(APPLICATION_PATH . '/modules/License/lib'),
realpath(APPLICATION_PATH . '/modules/Login/lib'),
]));
This tells PHP where to find ZF1 classes. The shardj/zf1-future package provides PHP 8.4 compatible versions of Zend Framework classes.
2. Register the ZF1 autoloader
$loader = new Zend\Loader\StandardAutoloader(['autoregister_zf' => true]);
$loader->register();
StandardAutoloader with autoregister_zf tells it to load any class starting with Zend_ or Zend\ from the include paths, PSR-0 style.
3. Boot ZF1 in-process
$application = new Zend_Application(APPLICATION_ENV, APPLICATION_PATH . '/configs/application.ini');
$application->bootstrap();
$front = $bootstrap->getResource('frontcontroller');
$front->returnResponse(true);
returnResponse(true) is critical. Without it, ZF1 echoes output directly to stdout, bypassing Symfony's response handling entirely.
4. Share the Symfony session with ZF1
$request->getSession()->start();
Zend_Session::$_unitTestEnabled = true;
Zend_Session::start();
$_unitTestEnabled is an internal flag meant for ZF1's own test suite. It bypasses all ini_set() calls inside Zend_Session::start() that would fail because Symfony already started the session. Without this flag, the session breaks silently.
5. Dispatch ZF1 and convert the response
$zfResponse = $front->dispatch(new Zend_Controller_Request_Http());
$symfonyResponse = new Response('', $zfResponse->getHttpResponseCode() ?: 200);
// Transfer headers
foreach ($zfResponse->getHeaders() as $header) {
$symfonyResponse->headers->set($header['name'], $header['value']);
}
// Handle raw headers set via PHP's header()
foreach (headers_list() as $headerLine) {
$parts = explode(':', $headerLine, 2);
if (count($parts) === 2) {
$symfonyResponse->headers->set(trim($parts[0]), trim($parts[1]));
}
}
// Transfer body
ob_start();
$zfResponse->outputBody();
$symfonyResponse->setContent(ob_get_clean());
ZF1's response object and Symfony's response object have different APIs. This conversion layer was the most tedious part of the bridge. Mismatched headers (Content-Type in particular) caused subtle rendering bugs that took weeks to fully resolve.
How Symfony routing decided what was migrated
The router's decision was simple: if a Symfony route exists for a URL, Symfony handles it. If not, the catch-all fires and ZF1 handles it.
This meant the migration order was determined by route registration. When we ported EmployeeController from ZF1 to Symfony, we registered the Symfony route and it automatically took over. No routing configuration changes were needed. The old ZF1 controller sat unused until deletion.
This approach has a practical consequence: a mistyped route means silent fallback. If a Symfony route was accidentally registered with a priority lower than -1000, it would silently fall through to ZF1, making it look like the migration had not happened. We caught this twice during the migration.
Error handling for legacy routes
ZF1 running on PHP 8.4 generates a lot of noise: notices, warnings, deprecation messages. The bridge suppressed them:
error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING & ~E_DEPRECATED);
ini_set('display_errors', '0');
This made legacy pages render correctly, but it also masked real errors. A ZF1 controller hitting a PHP 8.4 incompatibility would show a blank page instead of an error message. We recommended testing legacy pages manually after every PHP dependency upgrade.
The in-process Strangler Fig Router was the foundation that made the entire migration possible. It required no infrastructure changes, no reverse proxy, no separate deployment. Just a Symfony route and a controller that knew how to speak ZF1.
It was also the first thing we deleted when the migration was complete.
The next article covers the database bridge: how Zend_Db and Doctrine ORM shared a single PostgreSQL connection throughout the migration.
— Delaa