Featured Post

Migrating Legacy Laravel Apps: Lessons from 14 Years of PHP Development

A practical guide to upgrading Laravel 4.x-8.x applications to modern versions, with real-world strategies from enterprise migration projects.

Richard Joseph Porter
14 min read
laravelphplegacy-modernizationmigrationweb-development

After 14 years of PHP development and countless Laravel projects ranging from startup MVPs to enterprise platforms, I have learned that legacy migration is both an art and a science. The applications I have built and maintained---CoBabble, JOYO, Meishideseikyu, and many others---have all required upgrades at some point. Each migration taught me something new about navigating the treacherous waters between "it works" and "it's modern."

This guide distills those lessons into actionable strategies for upgrading Laravel applications from versions 4.x through 8.x to the latest releases, along with the PHP version upgrades that often accompany them.

Why Legacy Laravel Migration Matters Now

If your Laravel application is running on version 8.x or earlier, you are facing a convergence of pressures that make migration increasingly urgent.

Security Vulnerabilities: PHP 7.4 reached end-of-life in November 2022. Laravel 8 reached end of security support in January 2024. According to the Web Security Alliance Annual Report, 76% of hacked WordPress sites were running outdated PHP versions---and Laravel applications face similar risks when running unsupported versions.

Performance Gains: PHP 8.x delivers 20-30% performance improvements over PHP 7.4. Laravel 11 and 12 introduce optimizations that compound these gains, particularly in routing, service container resolution, and Eloquent queries.

Developer Productivity: Modern Laravel versions offer features like property hooks, improved Eloquent casting, streamlined application structure, and better tooling integration. These improvements accelerate development and reduce bugs.

Package Ecosystem: The Laravel ecosystem moves fast. Popular packages drop support for older Laravel versions, leaving legacy applications without access to security patches and new features.

Understanding the Migration Path

One of the most important lessons I have learned is that Laravel migrations should never skip major versions. The temptation to jump from Laravel 6 directly to Laravel 12 is understandable but dangerous.

The Incremental Upgrade Strategy

Laravel 4.x -> 5.0 -> 5.5 (LTS) -> 6.x -> 7.x -> 8.x -> 9.x -> 10.x -> 11.x -> 12.x

Each major version introduces breaking changes that compound when skipped. Laravel's upgrade guides are written assuming you are coming from the immediately previous version. Skip versions, and you are navigating undocumented territory.

For PHP, the same principle applies:

PHP 7.4 -> 8.0 -> 8.1 -> 8.2 -> 8.3 -> 8.4

I typically upgrade PHP first within a Laravel version, then upgrade Laravel. This isolates issues and makes debugging significantly easier.

Current Requirements (as of 2026)

Laravel 12 requires:

  • PHP 8.2 or higher (PHP 8.3+ recommended)
  • Composer 2.x
  • Various PHP extensions (OpenSSL, PDO, Mbstring, Tokenizer, XML, Ctype, JSON, BCMath)

Laravel 11 requires:

  • PHP 8.2.0 or greater
  • curl 7.34.0 or greater for the HTTP client

Phase 1: Pre-Migration Assessment

Before writing a single line of migration code, invest time in understanding what you are working with. This phase has saved me from countless disasters.

Audit Your Current State

Create a migration assessment document that captures:

<?php

// Create a simple audit script
// Save as scripts/migration-audit.php

$audit = [
    'laravel_version' => app()->version(),
    'php_version' => PHP_VERSION,
    'database' => config('database.default'),
    'cache_driver' => config('cache.default'),
    'queue_driver' => config('queue.default'),
    'session_driver' => config('session.driver'),
];

// Check for deprecated features
$deprecations = [];

// Check for dynamic properties (deprecated in PHP 8.2)
// Check for implicit nullable parameters
// Check for legacy helper usage

echo json_encode($audit, JSON_PRETTY_PRINT);

Dependency Compatibility Check

Run a comprehensive dependency analysis:

# Check composer dependencies for Laravel compatibility
composer outdated

# Use Laravel Shift's compatibility checker
# Visit: https://laravelshift.com/can-i-upgrade-laravel

# Generate a dependency report
composer show --tree > dependency-tree.txt

For each package, verify:

  • Does it support your target Laravel version?
  • Is there an alternative if it has been abandoned?
  • Are there breaking changes in the upgrade path?

Identify Custom Code Risks

Legacy Laravel applications often contain patterns that break in newer versions:

<?php

// RISK: String-based route actions (deprecated)
Route::get('/users', 'UserController@index');

// MODERN: Array syntax or invokable controllers
Route::get('/users', [UserController::class, 'index']);

// RISK: Dynamic properties on models (breaks in PHP 8.2)
$user->custom_attribute = 'value';

// MODERN: Use $fillable or custom accessors
protected $fillable = ['custom_attribute'];

// RISK: Implicit route model binding type hints
public function show($id)
{
    $user = User::findOrFail($id);
}

// MODERN: Explicit route model binding
public function show(User $user)
{
    // $user is automatically resolved
}

Phase 2: Establishing a Safety Net

I have learned the hard way that migration without comprehensive testing is gambling with your business. Here is how I establish a safety net before any migration work begins.

Characterization Testing

Before changing anything, write tests that document current behavior:

<?php

namespace Tests\Feature;

use Tests\TestCase;
use App\Models\User;
use App\Models\Invoice;

class InvoiceWorkflowTest extends TestCase
{
    /**
     * Characterization test: Document current invoice creation behavior
     * This test captures existing behavior, not desired behavior
     */
    public function test_invoice_creation_workflow_current_behavior(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)->post('/invoices', [
            'client_id' => 1,
            'amount' => 1000,
            'due_date' => '2026-02-01',
        ]);

        // Document current response structure
        $response->assertStatus(201);
        $response->assertJsonStructure([
            'data' => [
                'id',
                'invoice_number',
                'amount',
                'status',
                'created_at',
            ]
        ]);

        // Document current database state
        $this->assertDatabaseHas('invoices', [
            'client_id' => 1,
            'amount' => 100000, // Stored as cents
            'status' => 'draft',
        ]);
    }
}

Database Backup Strategy

Never migrate without a robust backup strategy:

#!/bin/bash
# scripts/backup-before-migration.sh

DATE=$(date +%Y%m%d_%H%M%S)
DB_NAME="your_database"
BACKUP_DIR="/backups/migration"

# Create backup directory
mkdir -p $BACKUP_DIR

# Full database backup
mysqldump -u root -p --single-transaction --routines --triggers \
    $DB_NAME > "$BACKUP_DIR/${DB_NAME}_${DATE}.sql"

# Verify backup
if [ $? -eq 0 ]; then
    echo "Backup created: ${BACKUP_DIR}/${DB_NAME}_${DATE}.sql"
    gzip "$BACKUP_DIR/${DB_NAME}_${DATE}.sql"
else
    echo "Backup failed!"
    exit 1
fi

Environment Isolation

Create a dedicated migration environment:

# Clone the production database to a migration environment
# Never migrate directly on production

# Create migration branch
git checkout -b migration/laravel-11-upgrade

# Set up migration environment
cp .env.production .env.migration
sed -i 's/DB_DATABASE=.*/DB_DATABASE=app_migration/' .env.migration
sed -i 's/APP_ENV=.*/APP_ENV=migration/' .env.migration

Phase 3: The Migration Process

With safety nets in place, the actual migration can begin. I follow a methodical process that minimizes risk at each step.

Step 1: Upgrade PHP First

If your application runs on PHP 7.4, upgrade to PHP 8.0 first while staying on your current Laravel version:

# Update PHP version in composer.json
{
    "require": {
        "php": "^8.0"
    }
}

# Run compatibility scan
composer require --dev phpcompatibility/php-compatibility
./vendor/bin/phpcs --standard=PHPCompatibility --runtime-set testVersion 8.0 app/

Common PHP 8.0 breaking changes to address:

<?php

// BEFORE: Named arguments can conflict with variadic parameters
function oldFunction($required, ...$options) {}
oldFunction(required: 'value', extra: 'data'); // Error in PHP 8.0

// AFTER: Be explicit about parameter handling
function newFunction($required, array $options = []) {}

// BEFORE: Null comparisons with non-nullable types
function process(string $value) {
    if ($value == null) { // Deprecated comparison
        return;
    }
}

// AFTER: Use strict comparisons or nullable types
function process(?string $value): void {
    if ($value === null) {
        return;
    }
}

Step 2: Framework Upgrade (Version by Version)

For each Laravel version upgrade, follow this checklist:

# 1. Update Laravel framework
composer require laravel/framework:^9.0 --update-with-dependencies

# 2. Update first-party packages
composer require laravel/sanctum:^3.0
composer require laravel/telescope:^4.0
# ... etc

# 3. Run upgrade commands
php artisan migrate
php artisan config:clear
php artisan cache:clear
php artisan view:clear

# 4. Run test suite
php artisan test

# 5. Manual smoke testing
# Test critical user journeys

Step 3: Address Breaking Changes

Each Laravel version has specific breaking changes. Here are the most impactful ones I encounter:

Laravel 9 Breaking Changes:

<?php

// Route::home() removed
// BEFORE
Route::home();

// AFTER
Route::get('/', function () {
    return view('welcome');
})->name('home');

// Symfony Mailer replaces SwiftMailer
// Update mail configuration and custom transports

Laravel 10 Breaking Changes:

<?php

// Minimum PHP 8.1 required
// BEFORE: Implicit nullable parameters
public function find(Model $model = null) {}

// AFTER: Explicit nullable type
public function find(?Model $model = null) {}

// Invokable validation rules required
// BEFORE: Rule class with passes() method
class OldRule implements Rule
{
    public function passes($attribute, $value) {}
    public function message() {}
}

// AFTER: Invokable rule
class NewRule implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (/* validation fails */) {
            $fail('The :attribute is invalid.');
        }
    }
}

Laravel 11 Breaking Changes:

<?php

// Application structure simplified
// Many files moved or removed by default

// Spatial columns rewritten
// BEFORE
$table->point('location');

// AFTER
$table->geometry('location');

// First-party package migrations must be published
php artisan vendor:publish --tag=sanctum-migrations
php artisan vendor:publish --tag=telescope-migrations

Phase 4: Handling Database Migrations

Database migrations during Laravel upgrades require special attention. Legacy applications often have accumulated schema quirks that modern Laravel handles differently.

Migration Column Type Changes

Laravel 11 changed how certain column types work:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('products', function (Blueprint $table) {
            // BEFORE Laravel 11: double with precision
            // $table->double('price', 8, 2);

            // AFTER Laravel 11: double without precision arguments
            // Use decimal for precision requirements
            $table->decimal('price', 10, 2);
        });
    }
};

Data Migration Strategies

For complex data migrations, I use a staged approach:

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use App\Models\LegacyUser;
use App\Models\User;
use Illuminate\Support\Facades\DB;

class MigrateUserData extends Command
{
    protected $signature = 'migrate:users {--dry-run}';
    protected $description = 'Migrate user data from legacy structure';

    public function handle(): int
    {
        $isDryRun = $this->option('dry-run');

        $this->info($isDryRun ? 'DRY RUN MODE' : 'LIVE MIGRATION');

        $legacyUsers = DB::table('legacy_users')->cursor();
        $migrated = 0;
        $failed = 0;

        foreach ($legacyUsers as $legacy) {
            try {
                $userData = [
                    'name' => $legacy->first_name . ' ' . $legacy->last_name,
                    'email' => strtolower(trim($legacy->email)),
                    'password' => $legacy->password, // Already hashed
                    'created_at' => $legacy->created_at,
                    'email_verified_at' => $legacy->verified ? now() : null,
                ];

                if (!$isDryRun) {
                    User::create($userData);
                }

                $migrated++;
            } catch (\Exception $e) {
                $this->error("Failed to migrate user {$legacy->id}: {$e->getMessage()}");
                $failed++;
            }
        }

        $this->info("Migrated: {$migrated}, Failed: {$failed}");

        return $failed > 0 ? Command::FAILURE : Command::SUCCESS;
    }
}

Phase 5: Testing and Validation

After migration, comprehensive testing validates that the upgrade preserves business functionality.

Automated Test Suite

<?php

namespace Tests\Feature;

use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Models\User;
use App\Models\Invoice;

class PostMigrationValidationTest extends TestCase
{
    use RefreshDatabase;

    /**
     * @test
     * @dataProvider criticalEndpointsProvider
     */
    public function critical_endpoints_respond_correctly(
        string $method,
        string $uri,
        int $expectedStatus
    ): void {
        $user = User::factory()->create();

        $response = $this->actingAs($user)->{$method}($uri);

        $response->assertStatus($expectedStatus);
    }

    public static function criticalEndpointsProvider(): array
    {
        return [
            'dashboard' => ['get', '/dashboard', 200],
            'invoices list' => ['get', '/invoices', 200],
            'invoice create form' => ['get', '/invoices/create', 200],
            'clients list' => ['get', '/clients', 200],
            'reports' => ['get', '/reports', 200],
            'settings' => ['get', '/settings', 200],
        ];
    }

    /**
     * @test
     */
    public function invoice_workflow_functions_correctly(): void
    {
        $user = User::factory()->create();
        $client = Client::factory()->create(['user_id' => $user->id]);

        // Create invoice
        $response = $this->actingAs($user)->post('/invoices', [
            'client_id' => $client->id,
            'amount' => 1500.00,
            'due_date' => now()->addDays(30)->format('Y-m-d'),
        ]);

        $response->assertStatus(201);
        $invoice = Invoice::latest()->first();

        // Send invoice
        $response = $this->actingAs($user)->post("/invoices/{$invoice->id}/send");
        $response->assertStatus(200);

        $this->assertDatabaseHas('invoices', [
            'id' => $invoice->id,
            'status' => 'sent',
        ]);
    }
}

Performance Benchmarking

Compare performance before and after migration:

<?php

namespace Tests\Performance;

use Tests\TestCase;
use App\Models\Invoice;

class PerformanceBenchmarkTest extends TestCase
{
    /**
     * @test
     */
    public function invoice_listing_performance(): void
    {
        // Seed test data
        Invoice::factory()->count(1000)->create();

        $startTime = microtime(true);
        $startMemory = memory_get_usage();

        $response = $this->get('/api/invoices?per_page=100');

        $endTime = microtime(true);
        $endMemory = memory_get_usage();

        $executionTime = ($endTime - $startTime) * 1000; // ms
        $memoryUsed = ($endMemory - $startMemory) / 1024 / 1024; // MB

        $response->assertStatus(200);

        // Log performance metrics
        $this->assertLessThan(500, $executionTime, 'Response time exceeded 500ms');
        $this->assertLessThan(50, $memoryUsed, 'Memory usage exceeded 50MB');

        // Record for comparison
        file_put_contents(storage_path('logs/performance.log'), json_encode([
            'test' => 'invoice_listing',
            'execution_time_ms' => $executionTime,
            'memory_mb' => $memoryUsed,
            'timestamp' => now()->toISOString(),
        ]) . "\n", FILE_APPEND);
    }
}

Real-World Migration Patterns

Drawing from projects like CoBabble, JOYO, and Meishideseikyu, here are patterns that have proven effective in production migrations.

The Strangler Fig Pattern

For large applications, gradual migration reduces risk:

<?php

// In routes/web.php
// Route requests to legacy or modern handlers based on feature flags

Route::middleware(['web'])->group(function () {
    // Modern routes (migrated features)
    Route::resource('invoices', InvoiceController::class)
        ->middleware('feature:modern-invoices');

    // Legacy fallback
    Route::any('invoices/{any?}', LegacyInvoiceController::class)
        ->where('any', '.*')
        ->middleware('feature:legacy-invoices');
});

// Feature flag middleware
class FeatureFlagMiddleware
{
    public function handle(Request $request, Closure $next, string $feature): Response
    {
        if (!Feature::active($feature)) {
            abort(404);
        }

        return $next($request);
    }
}

Parallel Running

Run old and new systems simultaneously during transition:

<?php

namespace App\Services;

use App\Models\Invoice;
use App\Legacy\InvoiceService as LegacyInvoiceService;
use Illuminate\Support\Facades\Log;

class InvoiceComparisonService
{
    public function __construct(
        private InvoiceService $modern,
        private LegacyInvoiceService $legacy,
    ) {}

    public function calculateTotal(Invoice $invoice): float
    {
        $modernResult = $this->modern->calculateTotal($invoice);
        $legacyResult = $this->legacy->calculateTotal($invoice);

        if (abs($modernResult - $legacyResult) > 0.01) {
            Log::warning('Invoice calculation mismatch', [
                'invoice_id' => $invoice->id,
                'modern' => $modernResult,
                'legacy' => $legacyResult,
                'difference' => $modernResult - $legacyResult,
            ]);
        }

        // Return modern result but log discrepancies
        return $modernResult;
    }
}

Tools That Accelerate Migration

Laravel Shift

Laravel Shift automates much of the upgrade process. While it is not free, the time savings justify the cost for most projects. It handles:

  • Framework version upgrades
  • Package updates
  • Code style changes
  • Deprecation fixes

Rector PHP

For automated PHP refactoring:

composer require rector/rector --dev

# Create rector.php configuration
./vendor/bin/rector init

# Run in dry-run mode first
./vendor/bin/rector process app --dry-run

# Apply changes
./vendor/bin/rector process app

PHPStan/Larastan

Static analysis catches issues before runtime:

composer require --dev phpstan/phpstan larastan/larastan

# Create phpstan.neon
includes:
    - vendor/larastan/larastan/extension.neon

parameters:
    level: 6
    paths:
        - app/

# Run analysis
./vendor/bin/phpstan analyse

Common Migration Pitfalls

After dozens of migrations, these are the issues that catch teams most often:

1. Skipping Version Steps

Problem: Attempting to jump multiple Laravel versions at once.

Solution: Always upgrade one major version at a time. The compounding breaking changes become unmanageable otherwise.

2. Ignoring Deprecation Warnings

Problem: Suppressing deprecation notices rather than fixing them.

Solution: Address deprecations before upgrading. They become errors in the next version.

<?php

// In phpunit.xml or bootstrap
error_reporting(E_ALL);

// Log deprecations during testing
set_error_handler(function ($errno, $errstr) {
    if ($errno === E_DEPRECATED) {
        Log::warning('Deprecation: ' . $errstr);
    }
    return false;
});

3. Forgetting Queue Workers

Problem: Queue workers continue running old code after deployment.

Solution: Implement proper queue worker restart procedures:

# In deployment script
php artisan queue:restart

# Or use Supervisor with graceful restart
supervisorctl restart laravel-worker:*

4. Session Compatibility

Problem: Serialized session data becomes incompatible after upgrade.

Solution: Plan for session invalidation:

<?php

// Clear all sessions during deployment
php artisan session:table
php artisan migrate

// Or in deployment
Schema::table('sessions', function (Blueprint $table) {
    DB::table('sessions')->truncate();
});

Post-Migration Optimization

Once the migration is complete, take advantage of new features:

Adopt Modern Eloquent Features

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;

class Invoice extends Model
{
    // Modern attribute casting (Laravel 9+)
    protected function amount(): Attribute
    {
        return Attribute::make(
            get: fn (int $value) => $value / 100,
            set: fn (float $value) => (int) ($value * 100),
        );
    }

    // Modern casts array (Laravel 11+)
    protected function casts(): array
    {
        return [
            'due_date' => 'date',
            'sent_at' => 'datetime',
            'metadata' => 'collection',
            'is_paid' => 'boolean',
        ];
    }
}

Implement Modern Routing

<?php

use App\Http\Controllers\InvoiceController;
use Illuminate\Support\Facades\Route;

// Modern route definitions
Route::controller(InvoiceController::class)->group(function () {
    Route::get('/invoices', 'index')->name('invoices.index');
    Route::get('/invoices/{invoice}', 'show')->name('invoices.show');
    Route::post('/invoices', 'store')->name('invoices.store');
});

// Singleton resource routes
Route::singleton('profile', ProfileController::class);

Key Takeaways

Migrating legacy Laravel applications requires patience, methodology, and thorough testing. After 14 years of PHP development, these principles guide every migration I undertake:

  • Never skip versions: Upgrade incrementally, one major version at a time
  • Test before, during, and after: Characterization tests document current behavior; regression tests validate preservation
  • Isolate PHP and Laravel upgrades: Upgrade PHP first within a Laravel version, then upgrade Laravel
  • Use automation wisely: Tools like Laravel Shift and Rector accelerate migration but require human oversight
  • Plan for rollback: Every migration should have a tested rollback procedure
  • Document everything: Future developers (including yourself) will thank you

Legacy code is not technical debt---it is business value accumulated over years. A well-executed migration preserves that value while positioning your application for another decade of growth.


Need help migrating your legacy Laravel application? I specialize in upgrading Laravel 4.x-8.x applications to modern versions with zero downtime. My Legacy Laravel Upgrade service includes comprehensive codebase audits, PHP version upgrades, automated testing implementation, and CI/CD pipeline setup. Schedule a free strategy call to discuss your project.


Related Reading:

External Resources:

Richard Joseph Porter - Professional headshot

Richard Joseph Porter

Full-stack developer with expertise in modern web technologies. Passionate about building scalable applications and sharing knowledge through technical writing.

Need Help Upgrading Your Laravel App?

I specialize in modernizing legacy Laravel applications with zero downtime. Get a free codebase audit and upgrade roadmap.