+1 (415) 599-8902

Upgrading a Laravel 8 App to Laravel 13: A Field Guide

Laravel 8 stopped receiving security fixes in January 2023. If you are still on it in 2026, you are five major versions behind: Laravel 13 shipped on March 17, 2026 and requires PHP 8.3 or newer. This is the process we use to get client applications across that gap without a rewrite and without a weekend of panic.

The short version: inventory first, test second, hop one major version at a time, and leave the frontend build until the framework is stable.

Step 0: Inventory before you touch anything

Start with facts, not optimism.

php -v
php artisan --version
composer outdated --direct
composer show --direct | wc -l
npm outdated

Then find the packages that will not make the trip. Composer tells you directly:

composer show --direct 2>/dev/null | awk '{print $1}' | while read pkg; do
  composer show "$pkg" 2>/dev/null | grep -q '^abandoned' && echo "ABANDONED: $pkg"
done

Typical findings on a Laravel 8 codebase:

  • laravelcollective/html — abandoned; does not work on Laravel 11+. Replace with spatie/laravel-html or plain Blade forms.
  • fideloper/proxy — removed in Laravel 9; trusted proxies moved into the framework's TrustProxies middleware.
  • fruitcake/laravel-cors — replaced by the framework's built-in HandleCors.
  • laravel/ui or an old laravel/jetstream — still installable, but check the version matrix for each hop.
  • Swift Mailer — replaced by Symfony Mailer in Laravel 9. Any custom mail transports need rewriting.
  • Flysystem v1 — Laravel 9 moved to Flysystem v3, which changed several Storage behaviours (for example, Storage::put no longer throws on failure by default).

Write every item down with its replacement. This list is the real scope of the upgrade.

Step 1: Build the regression safety net

Every hop is a chance to break something silently. Before changing a single version constraint, make sure the test suite can catch it.

If the suite is thin, add HTTP tests for the main user journeys. They are cheap and catch a disproportionate share of upgrade regressions:

<?php

use App\Models\User;

it('lets a user view their dashboard', function () {
    $user = User::factory()->create();

    $this->actingAs($user)
        ->get('/dashboard')
        ->assertOk()
        ->assertSee($user->name);
});

it('rejects guests from the dashboard', function () {
    $this->get('/dashboard')->assertRedirect('/login');
});

For the flows that make money (checkout, signup, the main form), a browser test is worth the setup. Pest 4 ships Playwright-backed browser testing, which is the modern replacement for Dusk:

<?php

it('completes checkout', function () {
    $page = visit('/products/1');

    $page->click('Add to cart')
        ->click('Checkout')
        ->fill('email', 'buyer@example.com')
        ->press('Place order')
        ->assertSee('Thank you');
});

Run the suite on the current version and record the result. A failing test that fails before you start is not an upgrade regression, and you want to know that now.

Step 2: Hop, don't jump

Go 8 → 9 → 10 → 11 → 12 → 13. Each hop is a pull request with a green CI run. The official upgrade guide for each version is the checklist; read it in full even when it looks long, because the "low impact" section is where the surprises live.

For each hop, the mechanics are the same:

# Edit composer.json: laravel/framework, laravel/sanctum, phpunit, etc.
composer update --with-all-dependencies
php artisan view:clear && php artisan config:clear
vendor/bin/pest

Notes from the field for the hops that hurt:

8 → 9. The biggest hop. PHP 8.0 minimum, Flysystem v3, Symfony Mailer, and the fideloper/proxy and fruitcake/cors removals above. Anonymous migrations become the default. Budget the most time here.

9 → 10. PHP 8.1 minimum. Native type declarations landed across the framework skeleton; if you have overridden framework methods (custom exception handlers, middleware, console kernels), their signatures need matching types or PHP will complain.

10 → 11. PHP 8.2 minimum and the slimmed application skeleton. You do not have to adopt the new bootstrap/app.php structure to upgrade, but it removes a lot of boilerplate and it is where new documentation assumes you are. We usually adopt it on this hop, in its own commit.

11 → 12. Minimal breaking changes by design. Mostly dependency bumps.

12 → 13. Also designed to be minimal. PHP 8.3 minimum, laravel/tinker ^3.0, pestphp/pest ^4.0, phpunit/phpunit ^12.0. Request-forgery protection is now formalised as PreventRequestForgery; if you have customised the CSRF middleware, review that section of the upgrade guide. The official estimate is about ten minutes for a typical application.

Shift or by hand?

Laravel Shift automates much of the mechanical work for each hop and is good value if you have several hops to do. It does not know about your abandoned packages or your business logic, so you still need the inventory and the tests. We use Shift for the skeleton and dependency churn, then do the package replacements and behavioural fixes by hand.

Step 3: PHP 8.3 gotchas

Upgrade PHP alongside the hop that requires it rather than all at once at the end. The deprecations that bite most often on Laravel 8-era code:

  • Dynamic properties (PHP 8.2). Setting $this->foo on a class that does not declare $foo is deprecated. Common in old service classes and test helpers. Declare the property or use #[\AllowDynamicProperties] as a stopgap.
  • Implicitly nullable parameters (PHP 8.4, but fix them now). function foo(Bar $bar = null) must become function foo(?Bar $bar = null).
  • String functions on null. strlen(null), str_replace with null subjects, and friends emit deprecations. Audit with grep -rn "strlen(\$" app/ and null-coalesce at the source.
  • utf8_encode / utf8_decode are deprecated; use mb_convert_encoding.

Run the suite with deprecations turned into errors in CI so none of these hide:

; phpunit.xml / pest config
error_reporting=E_ALL

Step 4: Laravel Mix → Vite

Once the framework is on 13 and green, migrate the asset build. Vite has been Laravel's default since Laravel 9, and Mix is maintenance-only. The migration is mostly mechanical — webpack.mix.js becomes vite.config.js, mix() calls in Blade become @vite([...]), and MIX_ environment variables become VITE_. We cover the details, including the pitfalls with static asset copying and HMR behind a proxy, in our Laravel Mix to Vite migration guide.

Doing this last matters: if something breaks, you want to know whether it was the framework or the build.

Step 5: Deploy and watch

Ship to a staging environment that matches production PHP and extensions, run the full suite there, then deploy with a rollback path (a tagged release and a reversible migration plan). Put Laravel Nightwatch or Pulse on it before the deploy so the first hour of production traffic tells you something. Queue workers must be restarted after the deploy (php artisan queue:restart), which is the single most common "it works in the browser but jobs are failing" cause after an upgrade.

What you get on the other side

A supported framework on supported PHP, a test suite you can trust, a Vite build, and access to everything that landed between 8 and 13: native PHP attributes on controllers, jobs, and models, Fortify passkey authentication, JSON:API resources, pgvector-backed vector search, and the first-party Laravel AI SDK.

If you would rather have this done for you, with the audit report and the pull requests landing in your repository, see our Laravel upgrade services or get in touch.