Loading ZF1 Classes on PHP 8.4
Zend Framework 1 was designed for PHP 5.2. Running it on PHP 8.4 requires a shim library, an autoloader bridge between PSR-0 and PSR-4, and module autoloaders for each legacy module. Here is the infrastructure that made ZF1 work on modern PHP.

Before you can run ZF1 inside Symfony 8, you need ZF1 to actually run on PHP 8.4. This was not trivial. Zend Framework 1 was written for PHP 5.2, released in 2006. Twenty years of PHP evolution separate the framework from the runtime we needed it to work on.
Three things made it possible: a community shim, a PSR-0 autoloader, and module autoloaders.
The shim library: shardj/zf1-future
The shardj/zf1-future package (v1.24.4 at the time of writing) is a community-maintained fork of Zend Framework 1 that backports PHP 8 compatibility fixes. It replaces the old zendframework/zendframework1 package which stopped receiving updates in 2016.
The critical fixes in v1.24.4:
- Dynamic property errors in Zend_Session. PHP 8.4 deprecates dynamic properties on standard classes. Zend_Session relies heavily on dynamic property access for session namespaces. The shim declares these properties explicitly.
- Zend_Pdf_Element type handling regression.
- fgetcsv escape parameter. PHP 8.4 deprecated the default escape parameter behavior in
fgetcsv; the shim passes the parameter explicitly.
Without this package, every ZF1 page would crash on PHP 8.4 with a Fatal Error: Dynamic property message. It is the unsung hero of the entire migration.
The autoloader bridge: PSR-0 meets PSR-4
ZF1 uses PSR-0 autoloading. A class called Zend_Controller_Action lives in Zend/Controller/Action.php. Symfony uses PSR-4 autoloading with Composer's generated classmap.
For the two frameworks to coexist, we needed both autoloading strategies active simultaneously. The LegacyAutoloader service handled this:
class LegacyAutoloader
{
private static bool $initialized = false;
public static function register(): void
{
if (self::$initialized) {
return;
}
// 1. Set include paths for ZF1 library + shim + module libs
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'),
]));
// 2. Register Laminas StandardAutoloader (PSR-0 for ZF1 classes)
$loader = new StandardAutoloader(['autoregister_laminas' => true]);
$loader->register();
// 3. Register module autoloaders for each ZF1 module
foreach (['HR', 'Payroll', 'Base', 'License', 'Login', 'System', 'Default'] as $module) {
$namespace = ($module === 'Default') ? '' : $module;
new Zend_Application_Module_Autoloader([
'namespace' => $namespace,
'basePath' => APPLICATION_PATH . '/modules/' . $module,
]);
}
self::$initialized = true;
}
}
Step 1 sets the PHP include path so that require_once 'Zend/Application.php' resolves correctly. Steps 2 and 3 register two autoloading mechanisms:
StandardAutoloader (from Laminas, the ZF1 continuation under the Linux Foundation) handles ZF1 core classes: Zend_Controller_*, Zend_Db_*, Zend_Auth_*, Zend_Session_*, and the ZendX_* extensions.
Zend_Application_Module_Autoloader handles module-specific classes. Each ZF1 module (HR, Payroll, Base, etc.) has its own set of controllers, models, forms, and views. The module autoloader maps the HR_ namespace prefix to application/modules/HR/ directory. For example, a class called HR_Controller_EmployeeController would be autoloaded from application/modules/HR/controllers/EmployeeController.php.
The initialization sequence
The autoloader was registered in public/index.php before the Symfony kernel booted:
// public/index.php
require_once APPLICATION_PATH . '/../vendor/autoload.php';
// Register ZF1 autoloading before Symfony boots
App\Domain\System\Service\LegacyAutoloader::register();
// Now Symfony can boot — its own autoloader coexists with ZF1's
$kernel = new Kernel($env, $env !== 'prod');
$response = $kernel->handle($request);
The order matters. If Symfony booted first and registered its PSR-4 autoloader, ZF1 classes would still be loadable (Composer's autoloader can coexist with other autoloaders). But module autoloaders needed the ZF1 infrastructure ready. The Laminas autoloader had to be registered before any ZF1 class was instantiated.
Why not Composer's autoloader for ZF1?
Composer can handle PSR-0 autoloading if you configure it in composer.json with the "psr-0" key. We tried this. The problem was that ZF1's module autoloaders do more than path mapping. They also initialize module-specific configuration, translation resources, and helper paths. Composer's autoloader only handles class resolution. The module autoloaders were required for ZF1 modules to function correctly.
The autoloader bridge was 46 lines of PHP. It was the first thing registered and the last thing removed. Without it, not a single ZF1 page would have rendered during the migration.
— Delaa