laravel 8 min read

Migrating Legacy PHP and CodeIgniter Apps to Laravel: Step-by-Step

Running a PHP 5.6 or CodeIgniter 2 application in production? Here is the exact migration strategy I use to modernise legacy PHP apps to Laravel without downtime.

G
Gurpreet Singh
February 12, 2026

The True Cost of a Legacy PHP Application

PHP 5.6 reached end-of-life in December 2018. PHP 7.4 in November 2022. CodeIgniter 2 has not received a security update since 2014. If your application runs on any of these, you are not just running old software — you are running software with known, unpatched security vulnerabilities, on a PHP runtime that your hosting provider will eventually refuse to support.

I recently completed a migration for a 7-year-old SaaS product running on CodeIgniter 2 and PHP 5.6. The application had no tests, no CI/CD, and 2-hour deployment windows that involved FTP file uploads and manual database changes run in phpMyAdmin. After migration to Laravel 12 and PHP 8.4: deployment time dropped from 2 hours to 9 minutes, zero critical bugs since go-live, and the development team's velocity tripled within the first sprint because they could use modern tooling.

This article is the migration strategy I used — the exact sequence, the common pitfalls, and the decisions that saved the project.

Phase 0: Understand Before You Rewrite

The most dangerous words in legacy migration are "we'll rewrite it from scratch." Rewrites almost always take 3x longer than estimated and frequently introduce regressions that were not obvious from reading the old code. The old code, however ugly, encodes years of business logic — some of it in the code, some of it in comments, some of it only in the original developer's head.

Before writing a single line of new code:

  1. Audit every entry point: Every route, every controller method, every cron job. Document what they do in plain language.
  2. Map the database schema: Export the schema and draw an entity-relationship diagram. Identify foreign key relationships that exist in application code but not in the database schema (common in old CodeIgniter apps).
  3. Identify the highest-risk functionality: Usually: authentication, payment processing, email sending, data exports. These get migrated and tested first.
  4. Set up logging on the legacy app: If it does not have request logging, add it now. After migration, you need to confirm that all traffic patterns the legacy app handled are being handled by the new app.

Phase 1: Parallel Infrastructure

Do not migrate in place. Stand up a separate environment for the new Laravel application that runs alongside the legacy app. Both apps share the same database during the migration period — this is the key to zero-downtime migration.

# Directory structure during migration
/var/www/legacy-app/     # Old CodeIgniter/PHP app (still live)
/var/www/new-laravel/    # New Laravel app (in development)

Configure your Nginx to route traffic to the legacy app by default, with the ability to switch specific routes to the Laravel app as they are completed and tested:

server {
    # Default: route everything to legacy app
    location / {
        proxy_pass http://127.0.0.1:8001;  # legacy app
    }

    # Migrated routes: route to new Laravel app
    location /api/ {
        proxy_pass http://127.0.0.1:8000;  # Laravel app
    }

    location /dashboard/ {
        proxy_pass http://127.0.0.1:8000;  # Laravel app
    }
}

This approach lets you migrate one section of the application at a time, test it thoroughly in production traffic, and roll back instantly by updating a single Nginx location block.

Phase 2: Set Up the Laravel Foundation

Database — Reverse-Engineer the Schema into Migrations

The legacy database schema becomes your first migration. Do not hand-write it — use a tool:

composer require --dev kitloong/laravel-migrations-generator
php artisan migrate:generate

This generates Laravel migrations from your existing database. Review them carefully — the generator will miss foreign key constraints that were enforced in application code only, and it will not know which columns should be nullable vs not-null.

After generating migrations, add the foreign key constraints that should have been there from the start:

Schema::table('orders', function (Blueprint $table) {
    $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
});

Authentication

Old CodeIgniter apps typically roll their own session-based authentication with MD5() or SHA1() password hashing (both completely broken). Laravel uses bcrypt by default.

You cannot migrate passwords without asking users to reset them — but you can support both hash formats during transition:

// app/Http/Requests/Auth/LoginRequest.php
protected function authenticate(): void
{
    $user = User::where('email', $this->email)->first();

    if (!$user) {
        throw ValidationException::withMessages(['email' => 'Invalid credentials.']);
    }

    // Support legacy MD5 passwords during migration
    if ($user->isLegacyPassword()) {
        if (md5($this->password) === $user->password) {
            // Upgrade to bcrypt on successful login
            $user->update([
                'password'            => Hash::make($this->password),
                'legacy_password'     => null,
                'password_upgraded_at' => now(),
            ]);
            Auth::login($user);
            return;
        }
    } elseif (Hash::check($this->password, $user->password)) {
        Auth::login($user);
        return;
    }

    throw ValidationException::withMessages(['email' => 'Invalid credentials.']);
}

Phase 3: Migrate Feature by Feature

The migration order matters. Work from least-risky to most-risky:

  1. Static and read-only pages — Marketing pages, help docs, user profile views. No writes, low risk.
  2. Authentication flows — Login, logout, password reset, registration. Thoroughly tested, then switched.
  3. Read/write features — CRUD operations for primary entities.
  4. Background jobs and cron tasks — Email sending, report generation, data cleanup.
  5. Payment and billing — Migrated last, with the most extensive testing.

For each feature, follow this sequence:

  1. Write the Laravel equivalent (controller, model, migration for any schema changes)
  2. Write feature tests that assert identical behaviour to the legacy version
  3. Deploy to staging with real production data (anonymised)
  4. QA review comparing legacy and new outputs side by side
  5. Switch Nginx routing for that feature
  6. Monitor error logs for 48 hours

Modernising the Database During Migration

The migration is the right time to fix database design decisions that have been causing pain. Common upgrades:

// Add timestamps that were missing
Schema::table('orders', function (Blueprint $table) {
    $table->timestamps(); // created_at, updated_at
    $table->softDeletes(); // deleted_at for soft delete
});

// Add proper indexes that were missing
Schema::table('orders', function (Blueprint $table) {
    $table->index(['user_id', 'status', 'created_at']);
    $table->index('stripe_payment_intent_id');
});

// Normalise serialised data stored as strings
// Old: orders.meta = 'a:2:{s:5:"color";s:3:"red";s:4:"size";s:2:"XL";}' (PHP serialized)
// New: orders.meta = '{"color":"red","size":"XL"}' (JSON)
Schema::table('orders', function (Blueprint $table) {
    $table->json('meta_json')->nullable()->after('meta');
});

// Backfill data migration
Order::whereNotNull('meta')->chunk(500, function ($orders) {
    foreach ($orders as $order) {
        $unserialized = @unserialize($order->meta);
        if ($unserialized !== false) {
            $order->update(['meta_json' => json_encode($unserialized)]);
        }
    }
});

Writing Tests for a Codebase That Had None

The legacy app had zero tests. Rather than trying to achieve high coverage of old behaviour, focus tests on the highest-risk paths:

// Test the auth transition (legacy hash upgrade)
public function test_user_with_legacy_md5_password_can_log_in_and_gets_upgraded(): void
{
    $user = User::factory()->create([
        'password' => md5('secret'),
    ]);

    $this->post('/login', ['email' => $user->email, 'password' => 'secret'])
         ->assertRedirect('/dashboard');

    // Password should now be bcrypt
    $this->assertTrue(Hash::check('secret', $user->fresh()->password));
    $this->assertNull($user->fresh()->legacy_password);
}

// Test that business logic matches legacy behaviour exactly
public function test_order_total_calculation_matches_legacy(): void
{
    $order = Order::factory()->create([
        'subtotal'  => 10000, // $100.00
        'tax_rate'  => 8.5,
        'discount'  => 1000,  // $10.00
    ]);

    // Assert the same calculation the legacy app performed
    $this->assertEquals(9715, $order->calculateTotal()); // $97.15
}

Cutover Day

The final cutover — switching all remaining traffic to Laravel — should be low-drama if you have followed the incremental migration approach. By cutover day, 70–80% of traffic is already hitting Laravel. What remains is typically the most complex legacy code that took longest to migrate.

Cutover checklist:

  • Put the legacy app in read-only mode (disable writes) one hour before cutover
  • Run any final data migrations
  • Update Nginx to route all traffic to Laravel
  • Verify health check endpoint returns 200
  • Monitor error rate for 30 minutes
  • Keep legacy app running (but not receiving traffic) for 72 hours as a rollback option

Frequently Asked Questions

How long does a PHP legacy migration typically take?

For a CodeIgniter 2 or PHP 5/6 application of moderate complexity (20–50 controllers, 30–50 database tables, 5,000–20,000 lines of application code): 8–16 weeks with one senior developer. The largest variable is test coverage — applications with zero tests take longer because every migrated feature requires manual QA instead of automated verification. The migration I described above (CodeIgniter 2 to Laravel 12) took 11 weeks with one developer working 30 hours per week on migration alongside maintaining the legacy app.

Can I keep CodeIgniter running during the migration?

Yes — and you should. The parallel infrastructure approach (both apps sharing one database, with Nginx routing traffic between them by route) is specifically designed to keep the legacy app running throughout the migration. You only retire the legacy app after 100% of traffic has been migrated and the new app has been stable for at least a week.

What do I do about legacy code I do not understand?

Black-box test it. Run the legacy code, observe its inputs and outputs, and write tests that assert those outputs. You do not need to understand why the legacy code does what it does — you need to replicate what it does in Laravel. Once your Laravel version passes those input/output tests, the migration for that feature is complete. Understanding can come later, after the legacy app is retired and you have breathing room to refactor.

#Laravel #PHP #Legacy #CodeIgniter #Migration #PHP 8.4 #Modernisation
G
Gurpreet Singh

Senior Full Stack Developer — Laravel, Vue.js, Nuxt.js & AI. Available for freelance projects.

Hire Me for Your Project

Related Articles