If you want to migrate legacy PHP to Laravel, the worst plan you can pick is the one everyone reaches for first: freeze the old app, rewrite it whole, ship the replacement on a big-bang launch day. I have watched that plan miss its date by a year and get cancelled with nothing in production. The legacy code keeps earning money and accumulating new requirements the entire time you are not shipping. The approach that actually works is the strangler-fig pattern: mount Laravel alongside the legacy app, route one feature at a time into Laravel, let everything else fall through to the old code, and delete legacy as Laravel takes over. The app never stops running, and you can pause at any point with value already shipped.
Why does a big-bang rewrite of a legacy PHP app fail?
A rewrite fails because you are betting the whole project on a single integration event months in the future, against a moving target. The legacy app is not frozen: sales wants a new report, a regulator changes a rule, a customer hits a bug, and every change has to land in two codebases or the rewrite drifts further from reality. Worse, nobody fully understands the old code. The weird discount logic that looks like a bug is load-bearing for one big client, and you only find that out after you 'fixed' it in the rewrite and the client churns.
The strangler-fig pattern, named after the vine that grows around a tree until the tree is gone and the vine stands on its own, removes the bet. You put a routing layer in front of the legacy app, send new and ported endpoints to Laravel, and let the rest pass through unchanged. Each ported feature ships independently. There is no launch day, no parallel maintenance of two full apps, and no point where the whole system is broken at once.
How do I mount Laravel alongside the legacy app and route incrementally?
The cleanest seam is at the web server. Put nginx in front of both apps: requests for paths you have ported go to Laravel's php-fpm pool, everything else falls through to the legacy front controller. You move one location block at a time, and the browser never knows two apps are involved.
server {
listen 80;
server_name app.example.com;
root /var/www/legacy/public;
index index.php index.html;
# Ported endpoints: hand off to Laravel's public/index.php
location ^~ /billing/ {
root /var/www/laravel/public;
try_files $uri /index.php?$query_string;
}
location ~ ^/billing/.+\.php$ {
root /var/www/laravel/public;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/laravel/public/index.php;
fastcgi_pass unix:/run/php/laravel.sock;
}
# Everything else falls through to the legacy app
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/legacy.sock;
}
}Run Laravel under its own php-fpm pool (laravel.sock) on a current PHP, 8.3 or newer, even while the legacy pool stays pinned to whatever ancient version it needs. Two pools, two PHP versions, one nginx. That decoupling is half the battle: you are no longer blocked on upgrading the legacy app's runtime before you can write modern code.
How do I share the session and database during the transition?
The hard part is not routing, it is making the two apps feel like one. A user who logs in on a legacy page and then clicks a link into a Laravel page must still be logged in. Be careful here, because sharing sessions is the part most guides get wrong. Laravel does not read a native PHP session out of the box: its EncryptCookies middleware encrypts the session cookie by default, and the data Laravel writes to the store is serialized in its own format, not the one PHP's session_encode produces. Pointing Laravel's redis driver at the legacy store and renaming the cookie to PHPSESSID is necessary but not sufficient. To actually share a session you have to add the cookie name to the $except array in EncryptCookies so Laravel stops encrypting it, and then write a custom session handler that reads and writes the legacy serialization, since the two payload formats are not interchangeable.
<?php
return [
// Read the same backing store the legacy app writes to
'driver' => env('SESSION_DRIVER', 'redis'),
'connection' => env('SESSION_CONNECTION', 'default'),
// Cookie name MUST match what the legacy app sets, e.g. PHPSESSID,
// so the browser sends one cookie both apps recognise. You must also
// add this name to the $except list in the EncryptCookies middleware,
// or Laravel encrypts it and the legacy app cannot read it.
'cookie' => env('SESSION_COOKIE', 'PHPSESSID'),
// Same domain and path so the cookie is shared, not duplicated.
'domain' => env('SESSION_DOMAIN', '.example.com'),
'path' => '/',
];In practice the payload-format mismatch is enough friction that many teams skip true session sharing and bridge auth at the user level instead: both apps point at the same users table, and each app keeps its own session but recognises the logged-in user from the same identity. Pick whichever costs less to maintain. For the database, do not migrate the schema. Point Laravel at the live legacy database and map Eloquent models onto the existing tables. Eloquent assumes conventions, plural snake_case table names, an id primary key, created_at and updated_at timestamps, and legacy schemas rarely follow them. You override every assumption explicitly so Eloquent reads the real table instead of demanding you reshape it.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Customer extends Model
{
// Legacy table name, not the 'customers' Eloquent would guess
protected $table = 'tbl_customer';
// Legacy primary key, not 'id'
protected $primaryKey = 'cust_id';
// Legacy schema has no created_at/updated_at columns
public $timestamps = false;
// Cast legacy column types to real PHP types on read/write.
// (Renaming a column to a friendlier name is a separate job:
// use an accessor, not $casts.)
protected $casts = [
'is_active' => 'boolean',
];
}Bridging auth is the same idea: reuse the legacy password hashes. If the old app used bcrypt, Laravel's Hash::check works against those hashes directly with no migration. If it used a weaker scheme like unsalted MD5, verify against the legacy algorithm on login and silently re-hash to bcrypt on success, so users upgrade transparently as they sign in. The non-negotiable is that a session created by either app authenticates the user in both. Anything less and people get logged out crossing the seam, and they will notice immediately.
What order should I port features in?
Sequencing decides whether this succeeds. Wrap the legacy app first: get the router and the shared session and database working with zero behaviour change, so you have proven the bridge before you have ported anything. Then carve out the first real feature. Pick either the highest-value slice (the one that pays for the project and earns you political room) or the highest-risk one (the module nobody dares touch), so you confront the scary part while the team still has appetite. As Laravel takes each feature, delete the legacy code that served it: dead code left behind gets edited by mistake and rots.
- Wrap first: stand up nginx routing, shared session, and shared database with no feature ported yet. Prove the seam is invisible to users.
- Carve out one feature: route its endpoints to Laravel, build them on the mapped Eloquent models, ship it behind the existing URLs.
- Add tests as you port. The legacy code almost certainly has none, so write characterization tests that pin down what it actually does before you reimplement it. Build the new code as testable service classes so the behaviour is locked down and provable.
- Retire legacy: once Laravel owns the feature in production and the tests are green, delete the old controller, scripts, and routes. Shrinking the legacy footprint is the whole point.
- Repeat, always leaving the system shippable. If priorities change, you stop with real value already in production, not a half-finished rewrite.
You are not rewriting the app, you are strangling it one endpoint at a time, and it keeps paying the bills the entire way.
What mistakes will sink the migration?
Two traps kill these projects. The first is porting bug-for-bug: copying the legacy logic verbatim, weird edge cases and all, because you are afraid to change behaviour. Sometimes that is correct, the weird discount really is load-bearing. But often you are faithfully reproducing an actual bug, and now it lives in clean new code where it looks intentional. This is exactly why characterization tests matter: they tell you what the code does so you can decide, deliberately, what to keep and what to fix.
The second trap is refactoring and migrating at the same time. When you are moving a feature into Laravel, move it: resist the urge to also restructure the database, rename columns, and redesign the API in the same step. Do one thing. Get the feature working identically in Laravel against the legacy schema first; refactor the schema later as its own change with its own tests. Mixing the two means that when something breaks you cannot tell whether the port or the refactor caused it. If your endgame is a service split rather than one big Laravel app, plan that as a separate phase. My notes on Laravel microservices architecture cover when carving services out is worth the operational cost and when it is premature.
The strangler-fig approach is slower to start than a clean-room rewrite and less satisfying: you spend the first week building plumbing that ships no features. But it is the only approach I have seen consistently finish, because it never asks the business to stop. Every endpoint you move is live the day you move it, every deletion shrinks the legacy surface, and the day the last legacy route goes dark, you are already fully on Laravel in production. No launch day, no rollback plan for a year of work, no bet. Just a legacy app that quietly disappeared while the product kept running.

