background-shape
Upgrading to Laravel 10, A Real-World Checklist From a Production Codebase
October 2, 2023 · 8 min read · by Muhammad Amal programming

TL;DR — Laravel 10 is a small upgrade on paper, big in your IDE / PHP 8.1 is the floor, native return types ripple through every override / The risky part is your own code drift, not the framework

I ran the Laravel 10 upgrade on a 4-year-old SaaS codebase last week. The official guide says it should take “less than an hour for most applications.” That is true if your app is a fresh skeleton. For a real app with custom service providers, overridden auth guards, and a half-dozen package forks, plan for a day, then another half-day of CI cleanup.

This is not a re-write of the upgrade guide. It is the actual sequence of failures I hit, the diffs that fixed them, and the order I would do it in next time. If you are sitting at Laravel 9.x and wondering whether to jump now or wait for Laravel 11 in February 2024, the short answer is: jump now, because every month you stall on 9 is technical debt you pay back at compound interest.

Laravel 11’s beta is already shipping breaking changes around the application skeleton. Landing on 10 first means you can absorb those one upgrade at a time instead of doing two big-bangs back to back.

Pre-Flight: What Actually Has to Be True

Before you change a single composer constraint, the boring stuff:

  • PHP 8.1 minimum. Laravel 10 dropped 8.0 entirely. If you are on 8.0 still, that is your real upgrade — Laravel is a side effect.
  • Composer 2.2+. Older Composer chokes on the platform constraints.
  • All your first-party packages need to support Laravel 10. Run composer why-not laravel/framework:^10.0 and read the output carefully. Spatie, Livewire, Nova, Sanctum — check each one.

The thing that actually catches people is the third bullet. I had a small internal package pinned to illuminate/support:^9.0 that nobody had touched in two years. Updating it took longer than the framework upgrade itself.

# Sanity check before you touch composer.json
php --version  # must be 8.1.x or 8.2.x
composer --version
composer outdated --direct
composer why-not laravel/framework:^10.0

If why-not returns anything other than “There is no installed package depending on this package”, stop and fix that first.

The Composer Diff

Here is the actual change. Nothing exotic:

{
    "require": {
        "php": "^8.1",
        "laravel/framework": "^10.0",
        "laravel/sanctum": "^3.3",
        "laravel/tinker": "^2.8",
        "guzzlehttp/guzzle": "^7.8"
    },
    "require-dev": {
        "fakerphp/faker": "^1.23",
        "laravel/pint": "^1.13",
        "nunomaduro/collision": "^7.10",
        "phpunit/phpunit": "^10.4",
        "spatie/laravel-ignition": "^2.3"
    }
}

Note PHPUnit jumped from 9 to 10. That is the second-biggest source of breakage after the framework itself. PHPUnit 10 removed setUpBeforeClass annotations in favor of attributes, and removed expectDeprecationMessage mid-deprecation. If your test suite has 800+ tests like mine did, expect 30-60 individual fixes.

composer update --with-all-dependencies
php artisan about

php artisan about is a Laravel 10 addition and worth a moment. It prints framework version, PHP version, cache drivers, queue connection, and environment all in one place. I now make a habit of pasting its output into deploy tickets.

The Native Type Hint Wave

This is the single biggest source of “wait, why doesn’t this compile” errors. Laravel 10 added native parameter and return types to public-facing APIs across the framework. If you override anything — Model, ServiceProvider, Authenticatable, custom guards, custom validators — your signatures have to match.

A concrete example. In Laravel 9 you might have:

class User extends Authenticatable
{
    public function getAuthIdentifier()
    {
        return $this->id;
    }
}

In Laravel 10, the parent declares:

public function getAuthIdentifier(): mixed

Your override now produces a fatal “must be compatible with” error. The fix is mechanical — add : mixed — but you have to do it everywhere. PHPStan level 5 will surface most of these. I ran it as a pre-flight and fixed 47 incompatibilities before the upgrade even started.

// before
public function newQuery() { ... }
public function getRouteKeyName() { ... }

// after
public function newQuery(): Builder { ... }
public function getRouteKeyName(): string { ... }

There is no shortcut. Open every class that extends a framework class and run a diff.

Deprecations That Bit Me

Three real ones from my migration:

1. dispatchNow() removed. Replace with dispatchSync(). If you use it in a test, this is silent: it just falls through to async dispatch and your assertion passes for the wrong reason.

// before
ProcessPayment::dispatchNow($order);

// after
ProcessPayment::dispatchSync($order);

2. Redis::connection()->command() return shape. Specific Redis commands now return the parsed type instead of a raw string in some cases. If you were doing (int) $redis->command('GET', ...), audit those.

3. Route::home() macro gone. This was deprecated in 9, removed in 10. The replacement is route('dashboard') or whatever your home-named route is. Search for Route::home( across your codebase.

The full list lives in the official upgrade guide, which is more accurate than any blog post including this one. Read it twice.

The Database Layer Surprises

Laravel 10 made some quiet but consequential changes to the schema builder:

// Native PostgreSQL "generated column" support
Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->decimal('subtotal', 10, 2);
    $table->decimal('tax', 10, 2);
    $table->decimal('total', 10, 2)
        ->storedAs('subtotal + tax'); // now works cleanly on PG 12+
    $table->timestamps();
});

The whereJsonContains operator on SQLite now actually works, which means tests that were passing-by-accident in SQLite-driven CI but failing in production-MySQL will now behave consistently. That is good. It also means a small number of tests that were passing-by-accident will now fail correctly. Mine had two.

If you use database transactions in tests, double-check DatabaseTransactions versus RefreshDatabase. The behavior with nested transactions changed subtly. I ended up adding an explicit DB::beginTransaction in one fixture that had been getting away with implicit nesting.

Validation Rule Classes: Worth the Migration

Laravel 10 introduced invokable validation rules as the default style. The old Rule contract with passes() and message() still works, but the new style is genuinely better:

<?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class IsValidVatNumber implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (! preg_match('/^[A-Z]{2}[0-9A-Z]+$/', $value)) {
            $fail('The :attribute is not a valid VAT number.');
            return;
        }

        if (! $this->existsInVies($value)) {
            $fail('validation.vat.not_registered')->translate();
        }
    }

    private function existsInVies(string $vat): bool
    {
        // call VIES SOAP service
        return true;
    }
}

The $fail closure pattern means you can short-circuit, return early, and emit multiple failures from one rule without juggling state on the class. Worth refactoring custom rules during the upgrade window while you have the file open anyway.

Common Pitfalls

A non-exhaustive list of things that went wrong on real codebases I have touched:

  • Pest 1.x is dead. If you use Pest, you need Pest 2 for PHPUnit 10. The Pest 2 syntax is mostly compatible but beforeEach semantics around dataset changed.
  • paginate() return type. It now declares LengthAwarePaginator explicitly. If you have a controller method type-hinted as Paginator, you will get a covariance error. Use the concrete class or Contracts\Pagination\LengthAwarePaginator.
  • Carbon 2.71+ required. Older Carbon versions throw on setTime() with a fourth microsecond argument that Laravel 10 internals pass.
  • Mailgun driver moved to a separate package. If you use it, composer require symfony/mailgun-mailer symfony/http-client. Same for Postmark and others — Laravel slimmed down its transport list.
  • Pulse, Pennant, Folio, Volt are not part of Laravel 10 core. They are first-party packages. Install them deliberately; do not assume they came along for the ride.

The Mailgun one cost me an hour because the queued mail just silently failed. Logs showed “Driver mailgun not supported” only when I ran the mail job synchronously. Worker logs were clean. Always test queues end-to-end after an upgrade.

CI and Deploy: The Bits Nobody Writes About

Your CI image needs a bump. If you pin to php:8.1-cli, fine. If you pin to a Docker tag like laravel/framework:9-fpm (please don’t), that has to change. I run mine on the official PHP image with extensions baked in:

FROM php:8.2-fpm-alpine3.18

RUN apk add --no-cache postgresql-dev icu-dev libzip-dev oniguruma-dev \
    && docker-php-ext-install pdo_pgsql intl zip mbstring opcache bcmath

COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer

WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
RUN composer dump-autoload --optimize --classmap-authoritative \
 && php artisan config:cache \
 && php artisan route:cache \
 && php artisan view:cache

The --classmap-authoritative flag is doing real work post-upgrade. With all the new type-hint plumbing, Composer’s autoloader sees more classes and the optimized classmap shaves measurable cold-start time on serverless deployments.

One last thing: after the deploy, run php artisan optimize:clear once, then php artisan optimize. The cached config from your Laravel 9 deploy is not just stale, it can reference classes that no longer exist.

Wrapping Up

Laravel 10 is the calmest major upgrade I have done in this codebase’s history, but “calm” is not “trivial.” The native types alone will keep you busy for an afternoon if your team has been writing untyped overrides for years. Do it now, before Laravel 11 lands in February, and use the upgrade as cover to clean up custom rule classes and shake out any test suite weirdness.

What I would do differently next time: run PHPStan to level 6 before starting, fix the noise first, then bump composer. The framework upgrade itself becomes a 20-minute exercise once the codebase is already type-clean. We’ll pick up clean architecture and how it changes the shape of an upgrade like this in the next post.