+1 (415) 599-8902

Migrating a Legacy PHP App to Laravel Without a Rewrite: The Strangler Fig Playbook

Most Laravel migration work we are asked to do does not start on a greenfield. It starts with a PHP application that earns real money, has ten years of history, no tests, a functions.php that is 4,000 lines long, and a business that cannot accept a six-month feature freeze. The big-bang rewrite is the instinctive answer and it is the one that fails: it burns the budget before parity, and the legacy app keeps moving while you chase it.

The alternative is the strangler fig pattern. You put Laravel in front of the legacy app, move one route at a time, and let the old system shrink until there is nothing left to switch off. Every week ships. Nothing is ever half-migrated in production.

This guide is the playbook we use on migrations from legacy custom PHP, CodeIgniter 3, Zend 1, and the older Laravel 4/5 generations.

Step 0: measure before you touch anything

You cannot strangle what you have not mapped. Spend the first week producing four artefacts:

  1. A route inventory. Every URL the app serves, with 30 days of traffic from access logs. Sort by hits. You will usually find that 20 URLs carry 80% of the traffic and several hundred carry almost none.
  2. A write inventory. Every endpoint, cron job, and background script that writes to the database or calls a third party. These are the risky ones.
  3. A schema map. Tables, row counts, foreign keys (declared and implied — legacy apps usually enforce relations in PHP, not in the database).
  4. An integration list. Payment gateways, mail providers, SFTP drops, webhooks pointing at the old domain. Each is a migration task of its own.
# quick route frequency map from nginx logs
awk '{print $7}' access.log | cut -d'?' -f1 | sort | uniq -c | sort -rn | head -50

Pick the first slice from the low-traffic, read-only quadrant. You want your first cutover to be boring.

Step 1: Laravel in front, legacy behind

Put a new Laravel application at the edge. Everything hits Laravel first; anything Laravel does not yet own is proxied to the legacy app, which now listens on an internal port or hostname only.

A catch-all fallback route is the simplest working proxy:

// routes/web.php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;

Route::fallback(function (Request $request) {
    $response = Http::withHeaders([
            'X-Forwarded-For'   => $request->ip(),
            'X-Forwarded-Host'  => $request->getHost(),
            'X-Forwarded-Proto' => $request->getScheme(),
        ])
        ->withOptions(['allow_redirects' => false])
        ->withBody($request->getContent(), $request->header('Content-Type', 'application/x-www-form-urlencoded'))
        ->send($request->method(), config('legacy.base_url').$request->getRequestUri());

    return response(
        $response->body(),
        $response->status(),
        collect($response->headers())
            ->except(['Transfer-Encoding', 'Content-Encoding', 'Content-Length'])
            ->map(fn ($v) => is_array($v) ? implode(', ', $v) : $v)
            ->all(),
    );
});

Three details that are not optional:

  • Do not re-compress or re-chunk. Strip Transfer-Encoding, Content-Encoding and Content-Length from the upstream response or browsers will receive garbage.
  • Stream large responses. File downloads and report exports should use a streamed response rather than buffering the whole body in PHP memory. For anything over a few megabytes, prefer an X-Accel-Redirect/X-Sendfile handoff or leave those paths on the legacy host entirely.
  • Preserve cookies and the original host. Legacy sessions usually bind to host and path.

If you would rather not pay the PHP round trip, the same split can live in nginx — location blocks for migrated prefixes pointing at Laravel, a default location / pointing at the legacy upstream. That is faster, but a route list in code is easier to review, test and roll back. Start in Laravel; move hot paths to nginx later if the latency shows up in your traces.

Step 2: share the session so users never notice

The migration is invisible only if a user logged in on a legacy page stays logged in on a Laravel page served from the same domain.

Two workable approaches:

Shared session store (preferred). Point both applications at the same Redis instance and make the legacy app write a session payload Laravel can read, or make Laravel read the legacy format with a custom session handler. If the legacy app uses raw PHP sessions, configure both with the same session.save_path and cookie name and have Laravel use a session driver that understands it.

Token handoff. Keep the legacy app as the identity provider during migration. On the first Laravel request without a session, call a signed internal endpoint on the legacy app with the legacy cookie, get back a user id, and log that user in:

namespace App\Http\Middleware;

use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;

class AdoptLegacySession
{
    public function handle(Request $request, Closure $next)
    {
        if (! Auth::check() && $request->hasCookie('LEGACYSESSID')) {
            $id = Http::withCookies(
                    ['LEGACYSESSID' => $request->cookie('LEGACYSESSID')],
                    config('legacy.cookie_domain'),
                )
                ->get(config('legacy.base_url').'/internal/whoami')
                ->json('user_id');

            if ($id && $user = User::find($id)) {
                Auth::login($user);
            }
        }

        return $next($request);
    }
}

Lock /internal/whoami to the Laravel host's IP and sign the request. Delete this middleware the day the legacy app stops serving pages — leaving a session-adoption backdoor in production after cutover is how a tidy migration becomes an incident.

Passwords usually migrate for free: legacy password_hash() output is bcrypt, which is exactly what Laravel verifies. Legacy md5 or sha1 hashes need a rehash-on-login shim that verifies the old format once, then stores a bcrypt hash and never looks back.

Step 3: model the legacy schema, do not worship it

Resist both extremes. Rewriting the schema on day one breaks the legacy app that is still running against it. Treating the legacy schema as sacred means you carry tbl_usr.usr_eml_addr forever.

The middle path: map it explicitly in Eloquent, hide the ugliness behind clean accessors, and change the physical schema later with expand-and-contract migrations.

class User extends Authenticatable
{
    protected $table = 'tbl_usr';
    protected $primaryKey = 'usr_id';
    public $timestamps = false;

    protected function email(): Attribute
    {
        return Attribute::make(
            get: fn () => $this->usr_eml_addr,
            set: fn ($value) => ['usr_eml_addr' => strtolower($value)],
        );
    }

    public function orders(): HasMany
    {
        return $this->hasMany(Order::class, 'ord_usr_id', 'usr_id');
    }
}

Two rules while both systems write to the same database:

  • One writer per concern. Once Laravel owns orders, the legacy app must stop writing orders. Enforce it by revoking write grants for the legacy database user on that table — a documented rule is not a constraint, a GRANT is.
  • Business rules live in one place. If the legacy app validates discount codes in PHP, do not reimplement the rule in Laravel from the code alone. Reimplement it from the data: run both implementations over a year of historical rows and diff the results before you cut over.

Step 4: migrate a slice, with a flag and a diff

Each slice follows the same loop:

  1. Build the route in Laravel behind a feature flag (Pennant makes this a one-liner).
  2. Enable it for internal users only. Click through it.
  3. Run it in shadow mode on read endpoints: serve the legacy response to the user, run the Laravel implementation in parallel on the queue, log any difference.
  4. Ramp: 5% of users, 25%, 100%.
  5. Remove the legacy code path and the flag.

Shadow comparison catches the pricing rounding difference, the timezone bug, and the sort order nobody documented:

use Laravel\Pennant\Feature;

Route::get('/invoices/{invoice}', function (Invoice $invoice) {
    if (Feature::active('laravel-invoices')) {
        return new InvoiceResource($invoice);
    }

    ShadowCompareInvoice::dispatch($invoice->id);   // logs diffs, returns nothing

    return $this->proxyToLegacy();
});

Keep the slice small enough to finish in a week. "Invoices" is a slice. "Billing" is a programme.

Step 5: characterisation tests, not a test suite

You will not retro-fit unit tests onto legacy logic you do not understand. What you can do cheaply is pin current behaviour from the outside:

it('matches legacy output for the top 50 pages', function (string $path) {
    $legacy = Http::get(config('legacy.base_url').$path);
    $new    = $this->get($path);

    expect($new->status())->toBe($legacy->status());
    expect(normalise($new->content()))->toBe(normalise($legacy->body()));
})->with(require __DIR__.'/fixtures/top_paths.php');

normalise() strips CSRF tokens, timestamps, and generated ids. These are not pretty tests and they are not permanent — they are scaffolding that tells you the day you changed behaviour by accident. Delete them slice by slice as real feature tests replace them.

Step 6: the things that bite

  • Cron jobs. Legacy crontabs are the most-forgotten half of a migration. Inventory them, port them to the Laravel scheduler with withoutOverlapping() and onOneServer(), and disable the old entry in the same change. Two schedulers running the same nightly billing job is a very bad day.
  • Webhooks. Third parties point at legacy URLs. Keep those URLs alive, routed through Laravel, long after cutover; you rarely control every sender's configuration.
  • Emails. Templates, footers and from-addresses are easy to lose. Port them deliberately and check SPF/DKIM alignment if the sending host changes.
  • File uploads. Legacy apps store files on local disk with path conventions embedded in the database. Move to S3 behind a Flysystem disk, backfill with a queued job, and keep a read-through fallback to the old disk until the backfill is verified.
  • SEO. URL structure changes need 301s, not 302s. Map old to new in a table, test the map, and keep it forever.
  • Timezones and encodings. latin1 columns and a server set to local time will produce subtle data corruption during dual-running. Fix them in a dedicated, isolated migration — never bundled with a feature slice.

Step 7: knowing when you are done

The migration is finished when the legacy application serves zero production requests for a full billing cycle, its cron entries are removed, its database user has no grants, and its hostname resolves to nothing. Only then do you archive the code and decommission the box.

Resist "we'll leave the old admin running, nobody uses it". That is how a strangler fig migration becomes two systems forever, with twice the attack surface and two PHP versions to patch.

A realistic shape

For a mid-sized application — a few hundred routes, a hundred tables, a couple of integrations — expect roughly: two weeks of inventory and proxy setup, then a cadence of one or two slices per fortnight, with the traffic-heavy 20% of routes migrated in the first third of the project and the long tail consuming the rest. Everything is in production from week three. There is never a weekend where the whole company holds its breath.

That property — no big-bang cutover — is the entire point. It is slower on paper than a rewrite and dramatically faster in practice, because a rewrite's schedule is a guess and a strangler fig's schedule is a measured rate.


Polish & Pixel runs migrations like this for clients on legacy PHP, CodeIgniter and older Laravel versions. See our Laravel Application Migration and Laravel Version Upgrades services, or get in touch with your route count and schema size and we will sketch a slice plan.