If you maintain a Zend Framework 1 application that still serves real traffic, this guide covers the practical work of bringing it forward without tearing it down to studs. Modernising a ZF1 codebase is a topic that sits squarely within PHP application architecture and long-term maintenance - the kind of problem you only truly understand after you have shipped production upgrades across multiple major framework versions. This page covers introducing Composer to a project that predates it, layering in PSR-4 autoloading, extracting service classes, adding tests to code that has none, modernising the database layer, replacing ZF1 configuration patterns, and planning realistic upgrade timelines. These are strategies I have used on real client projects where a full rewrite was never going to get budget approval.
For the broader architectural context behind these patterns, see the Survive the Deep End guides hub.
Why Incremental Modernisation Beats a Rewrite
The temptation with any ageing codebase is to declare bankruptcy and start fresh. I have watched three separate teams attempt ground-up rewrites of ZF1 applications. Two of those projects were cancelled before they shipped. The third delivered eighteen months late with a regression backlog that took another six months to clear.
Incremental modernisation works because it keeps the application in a deployable state throughout the process. You ship improvements continuously. The business keeps getting value. Developers stay motivated because they see results weekly rather than grinding toward a distant milestone that keeps moving.
The core principle is simple: draw a boundary around new code, enforce modern standards inside that boundary, and steadily expand the boundary until the old patterns are gone. Every technique in this guide serves that principle.
Introducing Composer to a ZF1 Project
Most ZF1 applications predate Composer entirely. The framework itself was typically installed by downloading a tarball or checking it out from SVN. Third-party libraries lived in a library/ or vendor/ directory managed by hand.
Start by creating a composer.json at the project root:
1 | { |
The classmap entry tells Composer to scan your existing library/ directory and build an autoload map for every class it finds there. This means your existing ZF1 classes, your custom library code, and any manually installed packages all become available through Composer’s autoloader immediately.
Run composer install and then add a single line near the top of your bootstrap or public/index.php:
1 | require_once __DIR__ . '/../vendor/autoload.php'; |
Place this before the ZF1 autoloader initialisation. Composer’s autoloader is fast and will defer to ZF1’s autoloader for anything it does not know about. With this in place, you can start pulling in modern packages through Composer while your existing code continues to work unchanged.
Layering PSR-4 Autoloading Alongside the ZF1 Autoloader
The psr-4 entry in the Composer configuration above maps the App\ namespace to a src/ directory. This is where all your new code goes. Every new class you write follows PSR-4 conventions: App\Service\EntryManager lives at src/Service/EntryManager.php.
The two autoloading systems coexist peacefully. When PHP encounters an unknown class, it walks through registered autoloaders in order. Composer’s autoloader checks its PSR-4 maps and classmap first. If it does not find the class, the ZF1 autoloader takes over and applies its own naming conventions (underscores as directory separators, prefix-based paths).
Over time, as you migrate classes from the old naming convention to PSR-4 namespaces, the classmap shrinks and the PSR-4 map grows. You can track this ratio as a rough measure of modernisation progress.
One practical note: if your ZF1 application uses Zend_Loader_Autoloader with custom namespace registrations, make sure those registrations still run after the Composer autoloader is in place. The order matters only if you have class name collisions, which is rare but worth checking.
Extracting Services from Controllers
ZF1 controllers tend to accumulate business logic over years of feature development. A controller action that started as ten lines of clean MVC code has, by now, grown to 200 lines mixing database queries, validation, email sending, and PDF generation.
The extraction process follows a consistent pattern:
- Identify a block of logic inside a controller action that does a single coherent thing.
- Create a new class under
App\Servicewith a method that accepts the necessary inputs and returns a result. - Move the logic into that method.
- Replace the original block in the controller with a call to the service.
- Wire the service into the controller through the constructor or a factory.
Here is a concrete before-and-after. Suppose your EntryController::createAction() contains:
1 | public function createAction() |
Extract the creation logic:
1 | namespace App\Service; |
The controller shrinks. The service is testable in isolation. You have created a seam in the code that lets you replace the database layer later without touching the controller. For a deeper look at this pattern with more examples, see the companion guide on refactoring fat controllers in PHP.
Adding Tests to Untested Code
Most ZF1 applications I have worked on have zero automated tests. Adding tests after the fact requires a different mindset than test-driven development. You are not designing the interface through tests - you are characterising existing behaviour so you can change it safely.
Start with characterisation tests. Pick a service class you have just extracted (or a model class that already exists) and write tests that document what it actually does today, including any bugs or quirks. Do not fix bugs in this step. The point is to have a safety net before you make changes.
1 | namespace Tests\Service; |
You will find that legacy code is often hostile to unit testing because of static method calls, global state, and tight coupling. This is expected. Use these strategies:
- Wrap static calls: create thin wrapper classes around
Zend_Auth::getInstance(),Zend_Registry::get(), and similar static access points. Inject the wrappers instead. - Extract and override: subclass the class under test and override the problematic method. This is a temporary measure, but it gets tests in place quickly.
- Integration tests first: if unit testing feels impossible, start with integration tests that boot the application and hit endpoints. They are slower but catch regressions immediately.
PHPUnit 9.x works well with PHP 7.4+ projects. If you are still on PHP 7.2 or 7.3, use PHPUnit 8.x. Get the test runner working first, even if you only have one test. The infrastructure matters more than coverage percentages at this stage.
Database Layer Modernisation
ZF1’s Zend_Db_Table abstraction served its purpose, but it encourages patterns that become painful at scale: table classes that mix query logic with business rules, row objects used as domain objects, and raw SQL scattered through controllers.
The modernisation path depends on your target:
Option A: Introduce Doctrine DBAL alongside Zend_Db
Doctrine’s DBAL is a standalone database abstraction layer that runs happily alongside Zend_Db. Install it through Composer, configure a second connection (or share the PDO instance), and start writing new queries through DBAL. Old code keeps using Zend_Db_Table until you migrate it.
1 | use Doctrine\DBAL\DriverManager; |
Sharing the PDO instance means both layers participate in the same transactions. This is important for data consistency during the migration period.
Option B: Move to PDO directly
If you do not need a query builder, injecting PDO instances into your service classes gives you full control without adding a dependency. Your new service classes accept a \PDO instance in the constructor, use prepared statements for all queries, and return plain arrays or simple value objects instead of Zend_Db_Table_Row instances.
Either way, the goal is to stop writing new code that depends on Zend_Db_Table. Existing code migrates gradually as you touch it for other reasons.
Replacing ZF1 Configuration Patterns
ZF1 uses application.ini with section inheritance (production, staging extends production, development extends staging). This works but has limitations: no PHP expressions, no environment variable interpolation without custom code, and the Zend_Config_Ini parser has known edge cases with certain value types.
Modern PHP projects use a combination of PHP configuration files and environment variables. During the transition, you can load both systems:
1 | // Legacy config still available |
The modern config file returns a plain PHP array and can pull from environment variables:
1 | return [ |
Inject configuration values into services through their constructors. Never have a service reach into a global registry to fetch its own config. This makes the dependency explicit and the service testable.
Introducing Middleware Concepts
ZF1’s plugin system (Zend_Controller_Plugin_Abstract) is a form of middleware, though it lacks the clean request/response pipeline that PSR-15 defines. You can introduce a lightweight middleware layer for new functionality without replacing the ZF1 front controller.
The approach is to create a thin adapter that runs PSR-15 middleware before or after the ZF1 dispatch cycle. In your public/index.php, you intercept the request before ZF1 sees it:
1 | $request = ServerRequestFactory::fromGlobals(); |
The LegacyBridgeMiddleware boots the ZF1 application, captures its output, and wraps it in a PSR-7 response. New API routes can be handled by middleware that runs before the bridge, returning early without ever touching ZF1.
This pattern works particularly well for adding new API endpoints to an application that is gradually moving away from ZF1’s MVC layer.
Timeline Planning for Upgrades
Realistic timelines depend on codebase size, test coverage, team size, and how much the business tolerates risk. Here is a rough framework based on projects I have delivered:
Small application (under 50 controllers, under 30 models): 3-6 months for a single developer working part-time on modernisation alongside feature work. Expect to spend the first month setting up Composer, the test runner, and the PSR-4 structure. Months 2-4 are extraction work. Months 5-6 are cleanup and removing the old patterns.
Medium application (50-200 controllers): 6-12 months with a dedicated developer or 12-18 months part-time. The extraction phase takes longer because there are more dependencies between components. You will need a tracking system (a spreadsheet works fine) to map which controllers have been modernised and which still use old patterns.
Large application (200+ controllers, multiple modules): 12-24 months with dedicated effort. Consider a strangler fig approach where new features are built in a separate modern application and requests are routed between the two systems at the reverse proxy level.
In all cases, set intermediate milestones that deliver real value: “All new code goes through Composer by week 4”, “Authentication service extracted by month 2”, “First integration test suite passing by month 3”. Avoid milestones that are purely structural with no visible result. The business needs to see returns from the investment, and developers need the motivation of shipping improvements regularly.
The worst mistake is treating modernisation as a project with a finish date. It is a continuous discipline. The goal is not to reach some mythical “fully modern” state - it is to make sure every piece of code you touch gets a little better than you found it.