Migrating from Zend to Laravel: A Step-by-Step Approach

Most of the time, there is no need to transition a project from one framework or technology to another. However, in some cases, migration becomes necessary to keep the system secure, efficient, and up-to-date. When it comes to migrating a codebase, there are a few ways to do it, with common approaches being a one-time switch or incremental migration.

One-Time Switch:

In this approach, we create a new application, set up the infrastructure, and move data from the old to the new system all at once. Many folks online refer to it as the “big bang.” Typically, during this migration, there’s a timeframe (usually during night) where operations can pause to finish the transition. If, for some reason, the migration takes longer than expected, it could affect the business’s working day.

Incremental Migration:

In this migration type, the Legacy and New systems run side by side. You move one feature at a time to the new system. This approach minimizes risks since you’re migrating small parts at a time. There’s no downtime, and you can quickly roll back to a previous state if needed. The downside is that additional efforts are needed to route functionality between the new and legacy systems.

Before beginning the migration, you can’t just halt work on the old system and dive into building a new version. Businesses depend on computer software, so you must maintain the existing system while transitioning to another. When businesses lack the budget and manpower to both build a new system and maintain or add new features to the existing one simultaneously, incremental migration proves quite handy in these scenarios.


I worked as a PHP developer for a company using Zend Framework v2 and an outdated PHP version in their custom ERP. This application managed the entire business, overseeing the supply chain, shipping, returns, and accounting. While I was reasonably familiar with Zend, my experience with Laravel was more extensive. In my observation, ZF2 had a steeper learning curve compared to Laravel, primarily due to its heavy reliance on arrays for everything—from route configuration to parameters—which made coding feel like a chore.

To keep the application efficient, performant, and secure, we needed to update the framework and PHP at some point. Upgrading Zend Framework to the latest versions was as challenging as migrating to Laravel. After weeks of discussions, we opted for Laravel due to its clear, concise, and easy-to-follow documentation, extensive out-of-the-box features, and its robust ecosystem.

Our team was small, and the company couldn’t afford additional manpower for the migration. Juggling the maintenance of the current system and adding new features while transitioning to Laravel proved challenging. We opted for the incremental migration approach. When a new feature request came in, we implemented it in Laravel. If we had to refactor or change something in the legacy app, we simply removed and re-implemented it in the new system. Despite these refactors taking some time, it was entirely worth it. We also committed to dedicating time each day to the migration, prioritizing the upgrade of framework parts that were slow and required significant improvements.


Adding a new feature or migrating an existing feature from the legacy application to the new Laravel app mostly requires creating new endpoints in the Laravel application and handling jobs during certain events in either the old or new app, such as a product being out of stock. We also need to figure out how to maintain authentication across both applications. We divided the migration into the following sections:

  • Nginx Configuration
  • Authentication
  • Messaging
  • Database Refactoring

Nginx Configuration

To cater to users, both systems had to run in parallel to route them to the application serving the feature. I set the Laravel application as the default. All requests were directed to the Laravel application when a user sent a request. In the Laravel application, if the route existed, we returned the response to the client. If the Laravel application returned a 501 response, Nginx detected it and proxied the request to the old ERP. This was the simplest way to ensure that we always served the newer version of endpoints from the Laravel application. Over time, more and more endpoints were migrated to the new ERP.

# Nginx config ....

location / {
    # Some config ...
    fastcgi_intercept_errors on;
    error_page 501 = @legacy;

location @legacy {
    # Some config ...
    # Proxy request to legacy app
    proxy_pass https://legacy.app.int;
}Code language: PHP (php)

I typically follow creating RESTful endpoints to maintain route consistency. One issue we encountered was that the old ERP did not adhere to these conventions. When migrating an endpoint to the new application, I usually changed the endpoint according to my conventions. There were a few route files that I added to my Laravel files—legacy_404.php, legacy_501.php, and legacy_redirect.php. These routes only existed during the migration process. Once the migration was completed, we removed these endpoints.

Legacy 501: Register the routes used in the Laravel app but still wanting to use the Zend implementation. This allowed us to use route(’name’) in Laravel application but the Zend implementation was used when users request this route.

Legacy redirect: Registered the routes that are implemented in the Laravel app with changed paths. This is only necessary if we still had links to the original path on the ZF. If the user request the old path, it will automatically redicreate to new route ddefined in Laravel application

Legacy 404: In this file, I register routes that we do not want to execute in the ZF, signifying that there is an alternative route available but redirect is not an option.


The old system was using a database-based session to authenticate employees. This allowed us to share the login session between the old and new ERP. I implemented a custom authentication guard that reads the session ID from the request and authenticates the user into the new application.

// AuthServiceProvider.php
class AuthServiceProvider extends ServiceProvider
    public function boot(): void
        Auth::extend('zend', function (Application $app, string $name, array $config) {
            return new ZendGuard(Auth::createUserProvider($config['provider']));
}Code language: PHP (php)

You can learn more about custom guards in Adding custom guards section of Laravel docs.


Our e-commerce website’s storefront was on Shopify. We frequently synchronized products and orders between the ERP and Shopify. Additionally, our ERP was connected to three dozen external partners through APIs or CSV uploads, with this list growing monthly. We had to push updates to Shopify and external partners whenever there were changes in the system, such as creating a new product, generating a quote for a customer, or marking a product as unavailable. We also synchronized orders from various partners and Shopify stores to the centralized ERP.

We were onboarding one or two partners every month, necessitating integration. These integrations were performed in the new ERP. However, there was an issue with this approach. If the old ERP made changes to the database, like modifying inventory to mark a product as unavailable, the new ERP needed to know what changed and send updates to partners accordingly.

There were a few different ways to address this. One approach was to create an endpoint so that when something changed in the old ERP, it would call the endpoint to notify the new Laravel application, allowing it to act accordingly. I didn’t prefer this because it would force us to make changes to the old ERP, and I wanted to avoid spending time on that.

Another solution was to observe changes to the database and act accordingly. I used Maxwell to capture changes to the MySQL database. Maxwell reads the MySQL binlog and writes the changes as JSON to different streaming platforms. I utilized Rabbit MQ to capture the changes from Maxwell, and these changes were queued for consumption by the Laravel application.

If you’d like to learn more about how I implemented this, feel free to reach out on Twitter or leave a comment below. I’ll write another post detailing the setup of Maxwell and Rabbit MQ.

Refactoring Database

This step was entirely optional, and we undertook it once we had successfully migrated the entire application to Laravel. The database was using inconsistent naming conventions for tables and column names. Some columns were in camel case, while others were in snake case. Many foreign keys were missing the “_id” suffix. We changed the table names and columns using migrations in small pull requests (PRs) and adjusted the code accordingly. Additionally, we made some improvements to the database by adding indexes to optimize slow queries.

It took us almost a year to incrementally migrate the entire application. By the time the migration was over, team productivity had significantly improved. During the migration, we made significant optimizations to the application to improve speed and overall user experience. With incremental refactoring, we were able to migrate to Laravel without dedicating our time solely to the new application and compromising maintenance and new feature requests.

As I wrap up this post, I’m currently seeking new opportunities in Laravel development. If you know of any exciting projects or positions available, I’d love to hear about them. Feel free to contact me at LinkedIn or Twitter @justsanjit. Let’s connect and explore possibilities together!

Leave a Reply

Your email address will not be published. Required fields are marked *