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.phpfiles in subdirectories. -
Database schema: Export a full ERD. Tools like SchemaSpy or even just
SHOW CREATE TABLEexports 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;
}
}
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
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;
}
}
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:
- Reproduce the functionality in a Laravel controller and Blade view (or Livewire component).
- Write feature tests that cover the happy path and the edge cases you found in the audit.
- Update the nginx config to proxy that URL prefix to Laravel.
- 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);
}
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]);
});
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, andview:cachein 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
ifstatement 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)