Last month, we covered the preparation for upgrading an ancient Laravel application from version 5.6 to 9. This month, we actually do it. No careful staging. No incremental commits. No upgrade guides. We are going full reckless abandon.
What gives us the audacity to skip five major upgrade guides? Tests. A comprehensive test suite describes how the application should behave. When tests fail after a change, we know exactly where the problem is. This confidence is what empowers the Dragon Drop method.
This upgrade process is not for the faint of heart. It is not for those who meticulously curate their commits. It is for teams who trust their test suite and want to get the upgrade done in hours, not weeks.
What Youâll Learn
- The Dragon Drop upgrade method: creating a fresh app and migrating code
- Handling model namespace changes from
app/toapp/Models - Updating factories from legacy syntax to modern class-based factories
- Converting old route declarations to class-based route syntax
- Configuring Laravel Scout, databases, and queues for the new version
- Managing composer dependencies during a major version bump
- Dealing with data migrations in database migrations
The Dragon Drop Method
The Dragon Drop method (say âdrag and dropâ fast) is brutal but effective. Create a fresh Laravel 9 application. Drop your old code on top. Let the test suite tell you what is broken.
# Create the fresh application
laravel new app
cd app/
# Install required packages
composer require laravel/ui
php artisan ui bootstrap --auth
composer require laravel/scout
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
composer require league/csv
composer require doctrine/dbal
composer require laravel/browser-kit-testing --dev
npm install && npm run devThis gives us a pristine Laravel 9 installation with all the packages we need. The application compiles. The welcome page loads. Now the real work begins.
Configuring the New Application
Before copying any code, configure the new applicationâs environment. The config folder is the heart of binding environment variables to application configuration.
<?php
// config/scout.php - Add MySQL driver configuration
'mysql' => [
'mode' => 'NATURAL_LANGUAGE',
'model_directories' => [app_path('Models')],
'min_search_length' => 0,
'min_fulltext_search_length' => 4,
'min_fulltext_search_fallback' => 'LIKE',
'query_expansion' => false,
],Update .env with the SCOUT_DRIVER=database setting. Configure config/app.php, config/mail.php, and config/services.php with settings from the old application. Review every config file for customizations.
Test Configuration and Setup
Remove the default test files that came with the fresh application:
rm tests/Feature/ExampleTest.php
rm tests/Unit/ExampleTest.phpCopy your existing tests from the old application. Our test suite covers donors, gifts, and referrals. Each test verifies that records can be created and updated, plus domain-specific requirements.
Since we removed the tests/Unit folder, remove the corresponding entry from phpunit.xml:
<!-- Remove this from phpunit.xml -->
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>Now run the tests:
./vendor/bin/phpunitExpect failure. The first error: Call to undefined function factory().
Fixing Factory Usage
The legacy application uses the global factory() helper. Laravel 9 uses class-based factories.
Old code:
$user = factory(App\User::class)->create();New code:
$user = \App\Models\User::factory()->create();Use your editorâs find-and-replace functionality across the entire project. This is where the ârecklessâ name pays off â you are making sweeping changes and relying on the test suite to catch any missed spots.
Updating Database Seeders
Old DatabaseSeeder:
public function run()
{
App\User::create([
'name' => 'admin',
'email' => '[email protected]',
'password' => Hash::make('admin'),
]);
factory(App\User::class, 5)->create();
factory(App\Referral::class, 15)->create();
factory(App\Gift::class, 35)->create();
}New DatabaseSeeder:
public function run(): void
{
\App\Models\User::factory()->create([
'name' => 'admin',
'email' => '[email protected]',
'password' => Hash::make('admin'),
]);
\App\Models\User::factory(5)->create();
\App\Models\Referral::factory(15)->create();
\App\Models\Gift::factory(35)->create();
}Model Namespace Changes
The legacy application stores models in app/ with the App namespace. Laravel 9 uses app/Models/ with the App\Models namespace.
Move each model file:
mv app/User.php app/Models/User.php
mv app/Donor.php app/Models/Donor.php
mv app/Gift.php app/Models/Gift.php
mv app/Referral.php app/Models/Referral.phpUpdate each modelâs namespace from namespace App; to namespace App\Models;. Add the HasFactory trait:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
use HasFactory;
// ... rest of the model
}Update every reference to the old namespace throughout the application. Search for App\User and replace with App\Models\User. Do the same for all other models.
Converting Factories
Legacy factories use the $factory->define() syntax. Modern factories are classes extending Illuminate\Database\Eloquent\Factories\Factory.
Use php artisan make:factory to create the new factory files:
php artisan make:factory GiftFactory --model=Gift
php artisan make:factory ReferralFactory --model=Referral
php artisan make:factory DonorFactory --model=DonorOld factory syntax:
$factory->define(App\Gift::class, function (Faker $faker) {
$referrals = \App\Referral::all();
return [
'description' => $faker->sentence,
'amount' => $faker->randomFloat(2, 0, 10000),
'referral_id' => $referrals->random()->id,
];
});New factory syntax:
<?php
namespace Database\Factories;
use App\Models\Gift;
use App\Models\Referral;
use Illuminate\Database\Eloquent\Factories\Factory;
class GiftFactory extends Factory
{
protected $model = Gift::class;
public function definition(): array
{
$referrals = Referral::all();
return [
'description' => fake()->sentence(),
'amount' => fake()->randomFloat(2, 0, 10000),
'referral_id' => $referrals->random()->id,
];
}
}Migrations
Copy your applicationâs migration files from the old database folder to the new one. Be careful not to overwrite Laravelâs built-in migrations (the 2014 and 2019 timestamped files).
# Copy only your application migrations
cp ../old-app/database/migrations/2020_* database/migrations/
cp ../old-app/database/migrations/2021_* database/migrations/
cp ../old-app/database/migrations/2022_* database/migrations/Data Migrations
Our application has a data migration that moves image metadata from the Referral model to a new VerificationImage table. Data migrations during database migrations can be risky, but for small datasets (a few thousand rows), the risk is acceptable.
<?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
{
$referrals = \App\Models\Referral::all();
foreach ($referrals as $referral) {
if (empty($referral->verification)) {
continue;
}
$input = [
'referral_id' => $referral->id,
'filename' => $referral->verification,
];
if (file_exists($referral->verification)) {
$input['hash'] = sha1_file($referral->verification);
}
\App\Models\VerificationImage::create($input);
}
Schema::table('referrals', function (Blueprint $table) {
$table->dropColumn('verification');
});
}
public function down(): void
{
Schema::table('referrals', function (Blueprint $table) {
$table->string('verification')->nullable();
});
}
};Run the migrations:
php artisan migrate
php artisan db:seedRoutes
Legacy Laravel used string-based syntax for route definitions. Laravel 9 uses class-based syntax.
Old syntax:
Route::get('/', ['as' => 'home', 'uses' => 'HomeController@index']);
Route::get('/referrals', ['as' => 'referrals.index', 'uses' => 'ReferralController@index']);
Route::post('/referrals', ['as' => 'referrals.create', 'uses' => 'ReferralController@create']);New syntax:
use App\Http\Controllers\HomeController;
use App\Http\Controllers\ReferralController;
Route::get('/', [HomeController::class, 'index'])->name('home');
Route::get('/referrals', [ReferralController::class, 'index'])->name('referrals.index');
Route::post('/referrals', [ReferralController::class, 'create'])->name('referrals.create');This applies to all routes. Update named routes, resource routes, and grouped routes.
Controllers and Form Requests
Copy the controller files and Form Request classes from the old application. Update namespace references for models App\User becomes App\Models\User.
<?php
namespace App\Http\Controllers;
use App\Models\Referral;
use App\Models\Gift;
use Illuminate\Http\Request;
class ReferralController extends Controller
{
public function index(): \Illuminate\View\View
{
$referrals = Referral::with('gifts')->paginate(20);
return view('referrals.index', compact('referrals'));
}
public function create(Request $request): \Illuminate\Http\RedirectResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:referrals,email',
]);
$referral = Referral::create($validated);
return redirect()
->route('referrals.index')
->with('success', "Referral {$referral->name} created.");
}
}After copying all controllers, run composer dump-autoload to regenerate the classmap.
Views and Frontend
Copy Blade templates from the old applicationâs resources/views folder. If your layouts and partials have not changed names, they should work without modification.
Frontend asset compilation is where many upgrades stall. The old application uses Laravel Mix (Webpack). Laravel 9 uses Vite by default. You have two options:
- Migrate to Vite (recommended for new projects)
- Keep Laravel Mix running (pragmatic for this upgrade path)
For a reckless upgrade, we keep the old frontend configuration working and migrate to Vite later. The tests pass, the application works, and we can iterate on the frontend separately.
# Keep Laravel Mix for now
npm install laravel-mix --save-dev
npm run productionRunning Tests
Now the moment of truth:
./vendor/bin/phpunit
PHPUnit 9.5.26 by Sebastian Bergmann and contributors.
....... 7 / 7 (100%)
Time: 1.07 seconds, Memory: 22.00MB
OK (7 tests, 28 assertions)All seven tests pass. Twenty-eight assertions confirm the application behaves correctly. We have successfully upgraded from Laravel 5.6 to 9, from PHP 7.1 to 8.2.
Committing the Changes
Version control purists, look away:
git checkout -b reckless-abandon-upgrade
rm -rf .
git commit -am "Scorched Earth - removed files"
cp -r ../app .
git add .
git commit -am "Laravel 9 upgrade with Dragon Drop method"
git push origin reckless-abandon-upgradeThe resulting pull request is massive. Hundreds of files changed. Thousands of lines added and removed. The commit history shows a clean slate â a full replacement of the old application with the new one.
But the test suite passes. The application is deployed. Users notice no difference except improved performance from PHP 8.2.
Real-World Use Cases
Legacy Application Modernization
A Laravel 5.1 application built in 2015 powers a growing SaaS platform. The team cannot upgrade to PHP 8 because Laravel 5.1 does not support it. Security patches for PHP 7.4 are ending. The Dragon Drop method moves the application to Laravel 9 in two days. The team can now deploy PHP 8.2 features, use modern Laravel packages, and attract developers who want to work with current technology.
Merger of Multiple Codebases
Two companies merge. Each has a Laravel application built on different versions. The Dragon Drop method upgrades both to Laravel 9, then the teams merge the codebases with a unified test suite. The test suite validates that both sets of functionality work correctly after the merge.
Best Practices
Have a Complete Test Suite
The reckless abandon method only works if your test suite covers the critical paths. Spend time writing tests before attempting the upgrade. Every untested feature is a risk.
Keep Changes Reversible
Use version control. Create a branch. If the upgrade fails, you can discard the branch and try again. The reckless method is not the irreversible method.
Run Tests Frequently
After each batch of changes, run the tests. The first failure tells you exactly what to fix next. Do not wait until all changes are made to run tests â you will lose the connection between the change and the failure.
Document Trade-offs
The reckless method leaves technical debt. Old frontend configurations. Deprecated method calls that still work. Keep a list of these trade-offs so the team can address them later.
Common Mistakes to Avoid
Without a test suite. Do not attempt this method without tests. You will not know what broke, and you will spend weeks debugging instead of hours.
Ignoring dependency compatibility. Verify that every Composer package supports the target Laravel version. A single incompatible package can break the entire upgrade.
Forgetting composer dump-autoload. After copying files into the new application, run composer dump-autoload. Otherwise, classes might not be found.
Modifying Laravel core files. Do not edit files in the vendor directory. If you need customization, use service providers or config files.
Frequently Asked Questions
Is the Dragon Drop method safe for production applications?
It is safe when backed by a comprehensive test suite and deployed with a rollback plan. Create a database backup before running migrations. Test the upgraded application in a staging environment before production.
How long does a Laravel 5.6 to 9 upgrade take?
With the Dragon Drop method, a skilled developer can complete the upgrade in one to two days. The time depends on test coverage, code complexity, and the number of custom packages.
What if my application uses deprecated features?
The test suite will catch runtime errors. Replace deprecated methods with their modern equivalents. Laravelâs upgrade guides provide detailed migration paths for deprecated features.
Should I upgrade Laravel version incrementally?
The reckless method jumps directly to the latest version. Incremental upgrades (5.6 to 6, 6 to 7, etc.) are safer but take much longer. Choose based on your risk tolerance and test coverage.
Can I revert to the old version if something goes wrong?
Yes. The git branch preserves the old codebase. You can revert by checking out the old branch and restoring the database from backup.
Conclusion
The reckless abandon upgrade method is not for every team or every application. It requires confidence in your test suite, tolerance for an ugly git history, and willingness to address issues as they appear.
But when it works, it works remarkably well. The application jumps from Laravel 5.6 to 9, PHP 7.1 to 8.2, and gains access to modern features, better performance, and continued security support. The upgrade that would take weeks of careful planning and incremental changes is completed in days.
The moral of the story: tests empower recklessness. Without them, you are gambling. With them, you are making a calculated bet.
Ready to upgrade your Laravel application? Start by writing tests for every critical feature. Then create a fresh Laravel 9 application, copy your code, and let the test suite guide your fixes. Your application will thank you.