Legacy to Laravel: How to Modernize an Aging PHP Application

Feature image: Legacy to Laravel: How to Modernize an Aging PHP Application

Here at Tighten, we love Laravel and get excited about staying up to date with the latest and greatest features our framework of choice has to offer. Who among us doesn’t get excited by typing laravel new, watching Composer wave in the latest packages like a third base coach, and then wading through fields of green as we are prompted to Build something amazing.

But let’s face it—as our industry ages and the number of legacy apps grows, we often find ourselves wrestling with outdated code while hopes of using the latest features demoed at Laracon fade away like a distant dream.

It can be tempting to want to rebuild legacy apps from scratch, but if you’ve ever tried it on an app of significant complexity you know it can be a trap. Apps that have existed in production for a long time generally meet the customers’ needs well enough to keep businesses afloat and are complex for a reason: they’re often the only source of truth about business requirements when the original stakeholders and developers have long since moved on. Complexity grows over time as apps expand to handle more edge cases, and tearing down years of work introduces unnecessary risks while often taking years of its own to complete.

The good news is, you don’t have to completely rewrite your legacy app to start using new Laravel features today! In this post, I’ll present some strategies we use at Tighten to convert legacy apps to Laravel gradually over time, without needing to perform a full rewrite. Whether you’re upgrading from an old framework or moving to a framework for the first time, following these steps will let you start benefitting from all that Laravel has to offer right away.

Install a New Laravel App

First we’ll start with a clean slate by installing a new Laravel app. If you don’t already have the Laravel installer, you can find instructions on how to install it here. Once you have the installer, run the following command in your terminal to install a new Laravel app:

laravel new upgrade-to-laravel

Move the New Laravel App Into the Legacy App

Next we’re going to move the contents of the new Laravel installation into our legacy app, and drop our legacy app down one directory into a legacy folder. Then we’ll set Laravel up to handle all incoming requests and pass any requests that don’t have defined routes through to the legacy app. This will also maintain the legacy app’s version control history.

  1. Create a new legacy directory inside of the legacy app
  2. Move the legacy app’s folders containing PHP files from the top level to the legacy folder
  3. Move all of the files and folders from the Laravel installation into the root folder of the legacy app
  4. Add the following legacy catch-all route to your Laravel app, at the bottom of routes/web.php:
use App\Http\Controllers\LegacyController;
use Illuminate\Support\Facades\Route;
 
Route::any('{path}', LegacyController::class)->where('path', '.*');
  1. Create a legacy controller with the following method:
public function __invoke()
{
ob_start();
require app_path('Http') . '/legacy.php';
$output = ob_get_clean();
 
// be sure to import Illuminate\Http\Response
return new Response($output);
}

Most legacy apps use echo to display their content, so the calls to ob_start() and ob_get_clean() allow us to capture that output into the $output variable using output buffering, so we can wrap it in a Laravel response.

  1. Create a new file at app/Http/legacy.php with the following:
// This is assuming the entry point to the legacy app is at `legacy/index.php`
require __DIR__.'/../../legacy/index.php';
  1. Depending on the configuration of the legacy app, the old entry point may need to be changed to account for being one level lower in the directory tree. For example, in a CodeIgniter 3.0 install, $system_path = 'system'; in index.php will need to be changed to $system_path = '../legacy/system';.

    Depending how the frontend is set up we may also need to move legacy frontend assets to Laravel’s public directory and ensure the legacy files that require them are referencing them with the correct path.

Once all of the files are in place and paths have been updated we should be able to visit our app in the browser and see our legacy app’s home page, which means it is running inside of the Laravel app!

Spring Cleaning

Now that we have our legacy app running through Laravel, it’s a good time to focus on a few important maintenance principles.

First, it is common for older apps to contain commented-out code or code that is unreachable and can’t possibly run. Before any code is moved to Laravel or refactored, it’s a good idea to first ensure that the code is actually in use so no one is wasting any time converting unused code. Any code that is commented out or confirmed to be unreachable should be deleted. As long as the app is under version control, we can always retrieve that code later if we need to.

Second, we may run across configuration settings inside the legacy app that would be better suited for a config file (located in /config) and then referenced with the config helper. Any security keys, or other sensitive settings that should not be committed to version control should be moved to an environment variable in the gitignored .env file and then added (with blank values) to .env.example. Once a setting has been moved to .env, we can also define it in a config file so references to environment variables are limited to the /config directory and Laravel will be able to cache our configs for better performance in production.

Testing the Home Page

Let’s imagine there are no existing tests for the legacy app. You are reasonably confident that everything works but the process of transforming the app introduces risk that things could break. We can use Laravel’s built-in testing features to ensure the app continues to work.

Let’s create a test for the home page by running the following in the terminal:

php artisan make:test HomePageTest

Now lets add a test that ensures the homepage loads successfully:

public function testExample()
{
// the entry point to the legacy app
$response = $this->get('/');
 
// replace 'welcome' with a string on the home page
$response->assertStatus(200)
->assertSee('Welcome');
}

At this point if all goes well we will see a passing test—but in our experience, there are a number of things that will cause simple tests like this to fail after a conversion.

PHP Superglobals

If the legacy code is referencing superglobals like $_REQUEST or $_SERVER, they should be changed to use Laravel’s request helper instead. References to $_REQUEST['some_param'], $_GET['some_param'], and $_POST['some_param'] can be changed to request('some_param') and references to $_SERVER['some_prop'] can be changed to request()->server('some_prop').

Global Variables

Any tests that run code referencing a global variable will fail if the test doesn’t first run the code where the variable is initialized. This can often be solved by moving the declaration of the global variable to app/Http/legacy.php, but usually the best option is to get rid of the global variable entirely and move it either to a config file (if it’s a simple value) or to Laravel’s Service Container (if it’s an object).

CSRF Tokens

By default, Laravel protects all form submissions with CSRF token verification via the VerifyCsrfToken middleware in the web route group defined in (app/Http/Kernel.php). In order to take advantage of the extra security CSRF tokens offer, we will need to include a CSRF token in each of our form submissions/POST requests. The csrf_field() helper method will generate a hidden input that does this.

However, if the legacy app contains more than a few forms we may want to temporarily disable the VerifyCsrfToken middleware on the legacy routes while we update our forms to include the token. Rather than simply commenting out the VerifyCsrfToken middleware—which would also disable it for any new routes—we can create a new legacy route group that uses the same middleware as the web group, but without VerifyCsrfToken.

To make a new legacy route group that excludes the CSRF middleware, we first need to define the route group in app/Http/Kernel.php as follows:

// within $middlewareGroups below 'web'
'legacy' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],

Next, we will move our legacy route to routes/legacy.php:

// routes/legacy.php
use App\Http\Controllers\LegacyController;
 
Route::any('{path}', LegacyController::class)->where('path', '.*');

Finally, we need to register the legacy route file by editing app/Providers/RouteServiceProvider.php as follows:

// Add the following to the end of the boot() method
Route::middleware('legacy')
->namespace($this->namespace)
->group(base_path('routes/legacy.php'));

Now our legacy forms will continue to work as they did before, without Laravel’s CSRF protection, while any new form submission routes can be defined in routes/web.php like normal and take full advantage of the security CSRF tokens provide.

Migrating to Eloquent

If your legacy app has a very complex data access layer, the idea of migrating all of that DB logic to Laravel’s Eloquent ORM may sound daunting. In order to ease this pain and minimize risk, we’ll break this process up into two steps:

Gathering Database Code

Generally, the data access layer will contain code with two responsibilities:

  1. Fetching or modifying data from the database
  2. Acting on data fetched from the database

These responsibilities might be encapsulated in model classes, or they might be scattered throughout the code base—or anywhere in between. We want to resist the urge to refactor this code in-place and instead focus on centralizing it, deferring the refactoring step until later. Separating these two steps helps build a stronger mental model of what the code is responsible for, and reduces duplication upfront. We also don’t want to clutter our brand new Eloquent models with code we know will need to be refactored. Instead, we will gather this database code in classes that sit between the Eloquent models and the rest of the codebase. We will call these classes Eloquent Shims.

Let’s say there is an items table, and code throughout the codebase is responsible for fetching one or more rows and performing operations on each item. Let’s start gathering this database code into an Eloquent Shim.

  1. First we’ll need to create the Eloquent model for the items table.
php artisan make:model Item
  1. Next we’ll create our Eloquent Shim file at app/EloquentShims/ItemShim.php:
<?php
 
namespace App\EloquentShims;
 
class ItemShim
{
protected $item;
 
public function __construct($item)
{
$this->item = $item;
}
}

Now we’re ready to gather the database code inside the new shim class. Imagine we have the following code:

// Inside a controller method
$items = OldItemModel::getFutureItems();
 
// OldItemModel.php
public static function getFutureItems()
{
// database query
}

First we’ll replace the controller code with the following:

$items = ItemShim::getFutureItems();

Then we’ll move the getFutureItems method from the OldItemModel class to our new ItemShim.

// ItemShim
public static function getFutureItems()
{
// database query
}

We can continue gathering more database code into the shim classes in a similar manner. For example, when we encounter code that fetches a single record by ID, we can find that item using Eloquent inline and instantiate the shim with it like so:

$item = new ItemShim(Item::find(123));

Any methods called on the $item object can now be moved from OldItemModel to ItemShim.

Refactoring the Shim Code

Once we are ready to move on to the refactoring phase we can focus our efforts on adding enough code to our Eloquent model to eliminate the need for the shim. Back to the earlier example, we have this method now in our shim class:

// ItemShim
public static function getFutureItems()
{
// database query
}

It might contain any amount of logic, but let’s imagine it contains a raw SQL query that simply fetches the future items from the database. Following Laravel conventions, we will want to implement a future query scope in our Item Eloquent model so we can refactor the body of the getFutureItems method to the following:

// ItemShim
public static function getFutureItems()
{
return Item::future()->get();
}

At this point our Eloquent model contains enough logic that the shim’s getFutureItems method is no longer needed. We can replace the call to the shim in the controller method with Eloquent directly.

$items = Item::future()->get();

Once all calls to getFutureItems have been replaced with the Eloquent query we can delete the getFutureItems shim method. We can then repeat this process until the shim contains no methods, and then we can delete the shim class entirely.

Stopping Execution Early

Legacy PHP applications often have calls to die or exit that immediately stop execution in place. This will interfere with Laravel’s request/response cycle, break extensions like Laravel Telescope, and prevent LegacyController from returning a response. The app might continue working as intended, but any attempts to write PHPUnit tests covering this code will fail because the PHP process will stop early—none of our assertions will run, and the test suite will come to a halt.

One solution that will preserve existing functionality while allowing PHP to continue execution is to remove these die and exit calls and throw/catch an exception instead. In order to generate an exception that we can consistently catch, we’ll create a new file at app/Exceptions/LegacyExitScript.php with the following:

namespace App\Exceptions;
 
use Exception;
 
class LegacyExitScript extends Exception
{
 
}

Then we’ll update our LegacyController@__invoke method to catch these exceptions and finish the request (don’t forget to import the LegacyExitScript exception!):

public function index()
{
try {
ob_start();
require public_path() . '/legacy.php';
$output = ob_get_clean();
} catch (LegacyExitScript $e) {
$output = ob_get_clean();
}
 
return new Response($output);
}

Now we can replace calls to die and exit with the following:

throw new LegacyExitScript;

If there are too many of these calls to reasonably test while replacing them with the LegacyExitScript exception, it may be a better idea to make these changes over time, while covering them with PHPUnit tests, rather than all at once.

Laravel Views

We want to make sure our application continues to meet our customers’ needs during its transition to a Laravel app, but we are also eager to take advantage of Laravel’s features. Business rules might dictate that we need to render a new page from an existing code path, or perhaps we need to modify an existing page and using a Laravel view would make the task much easier. Migrating an entire route or code path to a Laravel controller might be prohibitively time consuming, but there’s another solution! We can take advantage of the structure we put in place for our LegacyExitScript exception by throwing a view. Let’s create a new file app/Exceptions/LegacyView.php:

namespace App\Exceptions;
 
use Exception;
use Illuminate\View\View;
 
class LegacyView extends Exception
{
protected $view;
 
public function __construct(View $view)
{
$this->view = $view;
}
 
public function getView()
{
return $this->view;
}
}

We will need to catch this exception in LegacyController@__invoke in order to return the view as a response:

public function index()
{
try {
ob_start();
require public_path() . '/legacy.php';
$output = ob_get_clean();
} catch (LegacyExitScript $e) {
$output = ob_get_clean();
} catch (LegacyView $e) {
return $e->getView();
}
 
return new Response($output);
}

We could throw the LegacyView exception inline anywhere we want to call the view, but to make the calls a bit less verbose we can add a global helper method:

function legacy_view($view = null, $data = [], $mergeData = [])
{
throw new App\Http\LegacyView(
view($view, $data, $mergeData)
);
}

Our global function uses the same method signature as Laravel’s view helper, and takes care of throwing the LegacyView exception for us. Now anywhere in the legacy code that we want to stop execution and render a Laravel view we can just call our new helper method:

legacy_view('items.index', ['items' => $items]);
// anything below won't be executed

Throwing a view wrapped in an exception like this may be unconventional but remember, we’re not aiming for perfection on the first pass. Much like our Eloquent shims above, the LegacyView exception is a stepping stone towards a maintainable app. One reason we prepend these functions/classes with legacy is that they are designed to be a temporary solution. Once the app no longer needs them, they should be removed.

Generating Database Migrations

Whether or not the legacy app’s database was generated by migrations, we’re going to want to be able to use Laravel migrations going forward. In order to support running php artisan migrate in a new development environment or on an empty database, we’ll need to generate migration files from the existing database schema. At the time of writing, the oscarafdev/migrations-generator package is one of the easier ways to accomplish this.

Helper Methods

Often when moving pieces of legacy code to Laravel classes, we come across legacy code referencing a helper method that was previously included, but is now undefined when called from the new location. We want to make sure all of our helper methods are available to new code written (or moved to) Laravel, as well as the original legacy app. To do this we can set up a legacy helper file to be autoloaded globally so its methods are made available to the entire app.

Let’s create a new helper file at app/helpers/legacy.php, then autoload it in the "files" key of composer.json:

// Add this to the "autoload" property below "classmap"
"files": [
"app/helpers/legacy.php"
]

Once we run composer dump-autoload in the terminal, any method we define in app/helpers/legacy.php will be made available globally. Now that we have an easy place to put these methods, each time we run across a helper being called from code we have moved into Laravel we can simply move the referenced method from its legacy location into app/helpers/legacy.php.

Legacy Path Helper

Now that we have a good place to put legacy related helper methods, we can start adding new helper methods for common pieces of code in the legacy app. For example, legacy apps usually contain quite a few include and require statements to import other files. Ideally the paths given will be relative paths so they will continue to work as is, but sometimes full paths are used. In situations like these we might find ourselves needing to reference the new path to the legacy app subfolder. We can make this a bit easier on ourselves by adding a legacy_path helper method to the new app/helpers/legacy.php file:

function legacy_path($path = null)
{
return base_path('legacy/' . $path);
}

Converting Native PHP Sessions

Most legacy apps make use of native PHP sessions, but the degree to which sessions are relied on may vary widely. Sessions can be used for anything from simply managing user authentication to storing data from every request. Ultimately most people will want to convert their native session data to Laravel sessions—there are a few different strategies that can be used.

Search and Replace

If the app can reliably be tested (either by manual or automated tests), the easiest way to convert the sessions is to perform multiple search and replace calls across the app. Below is an example of using regex to convert setting session values from a native PHP session to a Laravel session.

// find: \$_SESSION\[(.+)] = (.+);
// replace: session([$1 => $2]);
 
// before
$_SESSION['foo'] = 'bar';
 
// after
session(['foo' => 'bar']);

Convert by Route

Another strategy is to convert native PHP session usage to Laravel only while moving functionality from the legacy app to newly defined Laravel routes. This strategy would mean fewer session variables need to be converted at once (the native and Laravel sessions will exist simultaneously) but when a session variable is converted, a search should still be performed to convert any other references to that same session variable elsewhere in the legacy app.

In Closing

Legacy apps come in all shapes and sizes, so your specific app might require custom changes or configuration we didn’t cover here. However, we hope these steps can serve as a baseline strategy for transforming a legacy PHP app into a Laravel app without having to undergo a full rewrite—and allow you to start using Laravel features immediately. Laravel is designed to make us happier and more productive developers, so there’s no reason we can’t continue supporting our legacy apps while taking advantage of all that our favorite framework has to offer!

Get our latest insights in your inbox:

By submitting this form, you acknowledge our Privacy Notice.

Hey, let’s talk.
©2024 Tighten Co.
· Privacy Policy