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.
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:
- How Developers Are Using Claude Code to Modernize Legacy Codebases
- Contact Form Security Best Practices
- Building a Modern Portfolio with Next.js 15
External Resources:
- Laravel 11 Upgrade Guide - Official Laravel Documentation
- Laravel 12 Upgrade Guide - Official Laravel Documentation
- Laravel Shift - Automated Laravel Upgrades
- PHP Migration Guides - Official PHP Documentation

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.
Related Articles
Laravel API Development Best Practices: A Complete Guide
Master REST API development with Laravel. Learn authentication, versioning, error handling, rate limiting, and performance optimization from 14 years of PHP experience.
AWS Cost Optimization for PHP Apps: A Complete Guide
Reduce your AWS bill by 40-70% with proven cost optimization strategies for PHP and Laravel applications. Covers EC2, RDS, Lambda, S3, and more.
Claude Code for Legacy Codebases: A Practitioner's Complete Guide
Master CLAUDE.md configuration, context management, and incremental workflows to transform legacy modernization with Claude Code. Battle-tested strategies from real-world projects.