Skip to main content
ArticlesProjects
Technical Article

Strangling Procedural Legacy PHP into Laravel

Published: 20 July 2026·13 min read·Category: Laravel

The big bang rewrite is the most confident decision an engineering team can make and one of the worst. The pitch is always reasonable. The current system is slow to change, everybody agrees it is slow to change, so we pause feature work, rebuild it properly, and come back in six months with something maintainable. What actually happens is that month four arrives, the business needs a feature the old system can still deliver and the new one cannot, and the rewrite is either shipped half finished or quietly abandoned. Now you have two systems to maintain instead of one.

That risk goes up when the thing you are replacing is not on an older framework. Migrating Symfony 2 to Laravel is unpleasant but legible. There are controllers, there is routing, there is a service container, and you can map concepts across. A procedural monolith gives you none of that. You get global state read straight out of $_GET and $_POST, mysqli_query calls interleaved with HTML, header('Location: ...') scattered through business logic, and includes that pull in half the application as a side effect. There is no seam to grab hold of, which is exactly why people conclude the only option is to start again.

There is another option. Put Laravel in front of the old application, migrate one route at a time, and let the procedural code carry on serving everything you have not reached yet. This is the strangler fig pattern, named after the vine that grows around a host tree, gradually takes over its structural role, and leaves a hollow shell behind. The important detail is that at no point during that process is the tree unable to stand.

Laravel as the front door

The first move is deciding who owns the incoming request. It has to be Laravel, and it has to be Laravel from day one, even when Laravel is handling exactly zero routes.

The web server serves Laravel’s public directory as the document root. Anything Laravel does not recognise falls through to the legacy entry point. As you migrate an endpoint, it stops falling through, and nothing about the client’s URL changes.

Request
|
v
Nginx -> Laravel public/index.php
|
+-- route matched? yes -> Laravel handles it
|
+-- no -> fallback to legacy/index.php

In Nginx that looks roughly like this:

server {
listen 80;
server_name my-app.test;
root /var/www/my-app/laravel/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location /legacy/ {
root /var/www/my-app;
try_files $uri $uri/ @legacy;
location ~ ^/legacy/.+\.php$ {
root /var/www/my-app;
try_files $fastcgi_script_name =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
}
}
location @legacy {
rewrite ^/legacy/(.*)$ /legacy/index.php?page=$1 last;
}
location ~ ^/index\.php(/|$) {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
}
}

Two details in there are worth calling out, because both of them have cost me time.

The first is that the legacy block uses root rather than alias. Reaching for alias is the obvious move when you are mapping a URL prefix onto a directory that sits outside your document root, and it does not work with try_files. This is nginx ticket 97, open since 2010. The value of $uri is not rewritten by alias, so try_files $uri re-appends the location prefix and goes looking for /var/www/my-app/legacy/legacy/whatever. It fails, silently, in a way that looks like the fallback is broken rather than the path. If you can arrange your directories so the legacy application sits under a shared parent, root sidesteps the whole thing. If you cannot, use a regex location with a capture and build the path yourself rather than relying on $uri.

The second is try_files $fastcgi_script_name =404 in the PHP block, and the narrowed ^/index\.php(/|$) pattern on the Laravel side. A bare location ~ \.php$ that passes anything ending in .php to FPM is the configuration behind a long history of remote code execution reports, because a request for /uploads/avatar.jpg/x.php can end up executing an uploaded file. Legacy applications tend to have upload directories inside the web root, which is exactly the situation where this matters.

The alternative approach, and the one I reach for when the legacy URL structure is chaotic enough that server config becomes unreadable, is to do the fallback inside Laravel itself using a catch all route that proxies to the old application. It is slower, because every legacy request now boots the framework before being handed off, but it puts the routing table in version control where you can read it, test it, and log it. On a system with meaningful traffic that trade is usually worth making for the first few months and worth reversing later.

Either way, the property you want is the same. There is one place that decides where a request goes, and migrating an endpoint is a one line change in that place.

The part that actually blocks people: sessions

Routing is straightforward. Authentication is where these migrations stall.

Procedural applications call session_start() and write to $_SESSION['user_id']. Laravel has its own session handling, its own cookie, its own encryption, and its own idea of what an authenticated user is. If you do nothing, a user who logs in through the old system hits a migrated Laravel route and gets bounced to a login form. Twice logged in is not a migration, it is an outage with extra steps.

The advice you will find most often is to make both applications share a session store. I want to talk you out of it, because it is harder than it sounds and the reason is not obvious.

PHP does not ship a database session handler. The values session.save_handler accepts are files, redis, memcached, and user, and user means you write a class implementing SessionHandlerInterface and register it with session_set_save_handler(). So step one is already a bespoke class in the codebase you were hoping not to touch.

Step two is worse. Laravel’s database session driver does not store a native PHP session blob. It stores base64_encode(serialize($attributes)) in a payload column, alongside id, user_id, ip_address, user_agent, and last_activity. And Laravel does not consider you logged in because a user_id exists somewhere in that payload. SessionGuard looks for a key named login_ plus the guard name plus a SHA1 of the guard class, which for the default web guard is a string like login_web_59ba36addc2b2f9401580f014c7f58ea4e30989d. For your legacy handler to produce a session Laravel accepts, it has to reproduce that format and that key exactly. It can be done. It is not the small mechanical change it appears to be, and it couples the old application to Laravel internals that are not part of the public API.

So there are really two honest options.

The first, and the one I now reach for by default, is to invert the problem. Make authentication the very first thing you migrate. Laravel owns login, logout, and the session, and the legacy application is changed to read whatever Laravel writes. That is a smaller change to the old codebase than a custom save handler, because a procedural app checking $_SESSION['user_id'] can be pointed at a single shared function that reads Laravel’s session or a signed cookie instead. You do the awkward work once, at the start, and every subsequent migration inherits a working auth context.

The second, for when login genuinely cannot move yet, is to read the legacy session yourself in middleware:

namespace App\Http\Middleware;
use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
final class BridgeLegacySession
{
public function handle(Request $request, Closure $next): Response
{
if (Auth::check()) {
return $next($request);
}
$cookie = config('legacy.session_cookie', 'PHPSESSID');
if (! $request->hasCookie($cookie)) {
return $next($request);
}
$session = DB::connection('legacy')
->table('sessions')
->where('id', $request->cookie($cookie))
->first();
if (! $session) {
return $next($request);
}
$userId = $this->extractUserId($session->data);
if ($userId && $user = User::find($userId)) {
Auth::login($user);
}
return $next($request);
}
private function extractUserId(string $data): ?int
{
$vars = [];
$offset = 0;
while ($offset < strlen($data)) {
$delimiter = strpos($data, '|', $offset);
if ($delimiter === false) {
break;
}
$name = substr($data, $offset, $delimiter - $offset);
$offset = $delimiter + 1;
$value = unserialize(substr($data, $offset));
$vars[$name] = $value;
$offset += strlen(serialize($value));
}
return $vars['user_id'] ?? null;
}
}

That parser exists because PHP’s default session serialisation format is not the same as serialize() output. It is a sequence of name|serialized_value pairs, and crucially the name carries no length prefix, which is why you cannot hand the whole thing to unserialize() and have to walk it byte by byte instead.

Be clear about where this is fragile, because the obvious guess is wrong. A pipe character inside a session value is fine, since values are properly serialised and the loop advances by strlen(serialize($value)) rather than searching for the next delimiter. What breaks it is a pipe character in a session key, which is legal and which nothing in the old codebase is stopping anyone from writing. The other soft spot is calling unserialize() on a substring that has trailing data after the value you want. That works, but how loudly PHP complains about it has changed across versions, so check the behaviour on the version you are actually deploying rather than trusting mine or anyone else’s.

There is a third failure mode with no workaround: if the session stores a serialised object whose class does not exist inside your Laravel application, unserialize() hands you a __PHP_Incomplete_Class and anything downstream that touches it throws.

So write this if you must, wrap the unserialize() call in error handling, log every failure loudly with the session id, and treat it as a temporary bridge you intend to delete rather than infrastructure you intend to keep. Register it in bootstrap/app.php on the web group and every migrated route gets a populated $request->user() for free.

Also set session.serialize_handler explicitly in the legacy application rather than assuming it. The default is php, which is what the parser above expects, but a codebase old enough to need this migration is old enough for someone to have changed it to php_binary for reasons nobody remembers.

Pointing Eloquent at a schema you did not design

Procedural codebases have their own naming conventions, and those conventions are usually somebody’s personal system from 2011. Tables prefixed tbl_, primary keys called usr_id_pk, timestamps stored as unix integers in a column named dt_created, and a deleted flag that is sometimes 0 and sometimes NULL depending on which script wrote the row.

The instinct is to fix the schema first. Do not. Schema migration is the last thing you do, not the first, because for the entire migration period both applications are reading and writing the same tables. Eloquent is perfectly happy to be told what the columns are called:

namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
final class User extends Authenticatable
{
protected $connection = 'legacy';
protected $table = 'tbl_users';
protected $primaryKey = 'usr_id_pk';
public const CREATED_AT = 'usr_created_dt';
public const UPDATED_AT = 'usr_updated_dt';
protected $fillable = [
'usr_email',
'usr_password',
'usr_name',
];
protected function casts(): array
{
return [
'usr_created_dt' => 'timestamp',
'usr_updated_dt' => 'timestamp',
];
}
public function getAuthPassword(): string
{
return $this->attributes['usr_password'];
}
}

The casts are not optional decoration. Naming the columns through CREATED_AT and UPDATED_AT tells Eloquent which columns to touch on write, but it does not tell it how to read them. If the legacy application stores unix integers rather than a datetime type, you need the timestamp cast or every date comparison you write will be doing string maths against an integer and quietly producing nonsense.

Two other things to check on a legacy table. If the primary key is a string or a UUID stored as a char column, set $incrementing to false and $keyType to 'string', because Eloquent assumes an auto incrementing integer and will cast your key out from under you. And if the table has no timestamp columns at all, which is common on lookup and join tables, set $timestamps to false or every write will fail on a column that does not exist.

You can go further and hide the naming entirely behind accessors, so the rest of your Laravel code never learns that usr_email exists:

use Illuminate\Database\Eloquent\Casts\Attribute;
protected function email(): Attribute
{
return Attribute::make(
get: fn (): string => $this->usr_email,
);
}

That is worth doing for the columns you touch most, because it means the day you finally rename the underlying column, you change one accessor rather than three hundred call sites. The model becomes a translation layer between the schema you inherited and the domain language you actually want.

One caveat on passwords, and it is not solved by getAuthPassword() alone. Legacy applications hash with whatever was fashionable at the time, and that is frequently MD5 or an unsalted SHA1. Laravel’s Hash::check() will not return false against those, it will fail outright, because it is handing a bcrypt hash string to password_verify(). You need to detect the legacy format and compare it yourself:

if (str_starts_with($user->getAuthPassword(), '$2y$')) {
$valid = Hash::check($plain, $user->getAuthPassword());
} else {
$valid = hash_equals($user->getAuthPassword(), md5($plain));
}
if ($valid && Hash::needsRehash($user->getAuthPassword())) {
$user->forceFill([
'usr_password' => Hash::make($plain),
])->save();
}

Use hash_equals() rather than a plain comparison so you are not leaking timing information, and rehash on every successful login so users migrate to bcrypt transparently as they come back. After a few months the legacy branch covers almost nobody, and you can force a reset for the stragglers and delete it. Do note that this rewrites the password column that the old application is still reading, so the legacy login code has to understand bcrypt before you turn this on. That ordering is easy to get backwards and locks people out when you do.

Choosing what to migrate, in what order

With ingress, sessions, and models in place, the strangling can begin. The order matters more than the pace.

Start with things that have no user interface. Scheduled jobs, webhook receivers, report exports, anything that runs on a cron and writes to a table. These have clear inputs and outputs, no session state, and no visual regression risk. Rewriting one as an Artisan command is a contained afternoon’s work, and it proves the plumbing works before anything user facing depends on it.

Then take read only pages. A listing screen or a detail view is low risk because getting it wrong shows up immediately and reverts cleanly. This is also where you establish the patterns the rest of the migration will follow, so spend the time getting the structure right rather than racing.

Write paths come next, and they come one at a time. A form that creates an order, a checkout flow, a settings update. These are where the business logic lives, which means they are also where the undocumented behaviour lives. Before you rewrite one, read the old code properly and write down every side effect you find, including the ones that look like bugs. Some of them are bugs. Some of them are load bearing, and the difference is a conversation with the business, not a judgement call you make alone.

Views come last, or rather views come alongside whatever you are already migrating. Replacing include 'header.php' with a Blade layout is not a project in itself, it is a thing that happens naturally as each page moves.

The step everybody skips is the final one. When a route is fully covered by Laravel, delete the legacy file and remove its fallback directive. If you do not delete it, it stays there, and eighteen months later nobody is sure whether it is still reachable. Deletion is the only proof that a migration finished.

Knowing whether it is working

A strangler migration that has no way of measuring progress will run forever, because there is always something more urgent than the next slice. Two numbers keep it honest.

The first is the count of requests still hitting the legacy fallback. Log it, graph it, and put the graph somewhere the team sees it. A line trending towards zero is the most persuasive migration status report you will ever write.

The second is the number of legacy files remaining. It is crude, and file size varies wildly, but it moves in one direction and it is impossible to argue with.

The reason this pattern works is not technical elegance. It works because it never asks the business to accept a period where nothing ships. Every week you deliver something, and every week a little more of the old system is gone. Nobody has to hold their nerve for six months, because nobody is ever more than a week from the next visible improvement.

That is the whole trick. Not a heroic rewrite, just a slow, boring, relentless narrowing of what the old code is still allowed to do.