# Running ZF1 Inside Symfony 8 With the Strangler Fig Pattern

Published 2025-12-17. Updated 2026-09-17.
Canonical URL https://log.delaa.dev/blog/2025-12-17-running-zf1-inside-symfony8-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.

---

<Image
  src="/images/strangler-fig-router-zf1-symfony8/hero.png"
  alt="A browser window with a legacy gray code panel and a modern blue code panel both routed through a central router icon"
  width={2000}
  height={1125}
  className="rounded-lg"
/>

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:

```php
// 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:

```php
#[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

```php
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

```php
$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

```php
$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

```php
$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

```php
$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:

```php
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.

The next article covers the database bridge. How Zend_Db and Doctrine
ORM shared a single PostgreSQL connection throughout the migration.

---

— Delaa
