DEV Community

Marcc Atayde
Marcc Atayde

Posted on

Migrating Legacy Websites to Modern Laravel: A Practical, Battle-Tested Guide

There's a particular kind of dread that hits when you open a legacy codebase for the first time. Global variables flying around, mysql_query() calls buried three functions deep, a functions.php that's 4,000 lines long, and zero tests. Yet the business depends on it. Customers are using it. And now you've been asked to modernise it.

Migrating a legacy site to Laravel isn't just a technical exercise — it's a translation job between two very different philosophies. This guide walks through a structured approach that lets you migrate incrementally, keep the existing system running, and avoid the classic trap of the never-ending rewrite.

Why Incremental Migration Beats the Big Bang Rewrite

The temptation is to freeze feature work, rewrite everything from scratch, and launch a brand-new system six months later. This almost never works. Requirements drift, the old system acquires patches the new one doesn't have, and teams lose momentum.

A strangler fig pattern — wrapping the new system around the old one, routing traffic piece by piece — is almost always the right call. Laravel is well-suited to this because its routing, middleware, and service container can coexist with legacy code running on the same server.

Step 1: Audit Before You Touch Anything

Before writing a single line of Laravel, map the existing system:

  • Entry points: Which files does the webserver actually serve? Often legacy PHP apps have dozens of index.php files in subdirectories.
  • Database schema: Export a full ERD. Tools like SchemaSpy or even just SHOW CREATE TABLE exports are your friends.
  • External dependencies: Payment gateways, SMTP config, third-party APIs — document every outbound connection.
  • Session and auth model: Is auth session-based? Cookie-based? Custom token table?

Document this ruthlessly. You'll refer back to it constantly.

Step 2: Stand Up Laravel Alongside the Legacy App

Install a fresh Laravel application in a sibling directory. Configure your web server (nginx is easiest here) to proxy requests to either the legacy app or Laravel based on URL prefix.

server {
    listen 80;
    server_name example.com;

    # New Laravel routes
    location /account {
        proxy_pass http://127.0.0.1:8001;
    }

    location /api {
        proxy_pass http://127.0.0.1:8001;
    }

    # Everything else goes to the legacy app
    location / {
        root /var/www/legacy;
        index index.php;
        fastcgi_pass unix:/run/php/php7.4-fpm.sock;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}
Enter fullscreen mode Exit fullscreen mode

Laravel runs on PHP 8.x on port 8001 via a separate FPM pool. The legacy app continues to handle everything else untouched.

Step 3: Shared Session State

The single hardest problem in this migration pattern is auth. Users logged into the legacy system shouldn't be asked to log in again when Laravel handles a request.

The pragmatic solution: share the session backend. If the legacy app uses PHP file sessions, Laravel can read those too — but it's messy. Better to migrate the legacy app to use a database or Redis session store first, then configure Laravel to use the same store.

// config/session.php in Laravel
'driver' => 'redis',
'connection' => 'default',
'cookie' => 'legacy_session', // must match the legacy app's cookie name
Enter fullscreen mode Exit fullscreen mode

On the legacy side, use a custom session handler that writes to Redis with the same key format. PhpRedis or Predis works fine here. This buys you true single-sign-on between the old and new systems without a full auth migration upfront.

Step 4: Wrap the Legacy Database in Eloquent Models

Don't rename tables. Don't normalise the schema yet. Just point Eloquent at what's already there.

class LegacyUser extends Model
{
    protected $table = 'tbl_users'; // old naming convention
    protected $primaryKey = 'user_id';
    public $timestamps = false; // no created_at/updated_at columns

    protected $casts = [
        'is_active' => 'boolean',
        'created' => 'datetime', // maps the legacy column name
    ];

    public function getCreatedAtAttribute()
    {
        return $this->attributes['created'] ?? null;
    }
}
Enter fullscreen mode Exit fullscreen mode

This gives you the full power of Eloquent — relationships, scopes, query builder — without touching the database. Schema refactoring comes later, after you've proven the migration works.

Step 5: Migrate Routes Module by Module

Pick the least risky, most self-contained module first. Admin dashboards, reporting pages, or internal tools are ideal. Avoid payment flows and the core checkout until you've built confidence.

For each module:

  1. Reproduce the functionality in a Laravel controller and Blade view (or Livewire component).
  2. Write feature tests that cover the happy path and the edge cases you found in the audit.
  3. Update the nginx config to proxy that URL prefix to Laravel.
  4. Monitor error logs for 48–72 hours before moving to the next module.
// Example feature test to lock in behaviour before refactoring
public function test_order_history_displays_for_authenticated_user(): void
{
    $user = LegacyUser::factory()->create();
    $orders = LegacyOrder::factory()->count(3)->for($user)->create();

    $response = $this->actingAs($user)->get('/account/orders');

    $response->assertOk();
    $response->assertSee($orders->first()->order_ref);
}
Enter fullscreen mode Exit fullscreen mode

Tests written against the legacy behaviour become the safety net for every subsequent refactor.

Step 6: Schema Migration — When You're Ready

Once a module is fully running on Laravel and tested, you can consider schema normalisation. Use Laravel migrations with Schema::table() to add columns, rename things, and introduce proper indexes — but do it additively at first. Keep the old column, write to both, verify data integrity, then drop the old column in a later migration.

Schema::table('tbl_users', function (Blueprint $table) {
    $table->string('email')->nullable()->after('user_email');
    $table->index('email');
});

// Backfill in a job, not in the migration itself
LegacyUser::whereNull('email')->eachById(function ($user) {
    $user->update(['email' => $user->user_email]);
});
Enter fullscreen mode Exit fullscreen mode

Never backfill large datasets in a migration. It locks the table and causes downtime.

Step 7: The Final Cutover

When all routes are handled by Laravel and the legacy entry points are dead code, you're ready to cut over fully. At this point:

  • Rename or archive the legacy directory.
  • Update nginx to point the root location directly at Laravel's public/ folder.
  • Remove the legacy FPM pool.
  • Run php artisan route:cache, config:cache, and view:cache in production.

Keep the legacy codebase archived for at least 90 days. You will want to reference it.

Hard-Won Opinions From Production

A few things learned from doing this on real client projects — including migrations handled through work at hanzweb.ae — that don't get mentioned in tutorials:

  • Don't let perfect be the enemy of shipped. Ugly Eloquent models that mirror a messy schema are fine. Clean them up once the system is stable.
  • The legacy app will have undocumented business logic. Read the old code before deleting it. Every mysterious if statement has a story.
  • Logging is your best diagnostic tool. Add structured logging via Monolog from day one. You'll thank yourself when something breaks in production at 2am.
  • Communicate with stakeholders about the strangler fig timeline. Migrations of this kind take months, not weeks. Set that expectation early.

Conclusion

Legacy migrations succeed when they're treated as a series of small, verifiable steps rather than a single heroic rewrite. The strangler fig pattern, shared sessions, Eloquent wrappers over existing tables, and feature tests written against legacy behaviour — these aren't glamorous techniques, but they're the ones that actually ship.

The end state is worth the effort: a codebase you can reason about, test confidently, and hand off to another developer without writing a 40-page onboarding document.

Top comments (0)