Laravel Laravel Development

    Laravel 12 Breaking Changes You Must Fix After Upgrade

    December 29, 2025
    Updated: September 17, 2026
    Larastaff
    14 min read
    Laravel 12 Breaking Changes You Must Fix After Upgrade

    One of our client projects uses Net 30 payment terms, with a late fee added after 30 days. The check behind it looks simple: diffInDays() > 30. On Laravel 12, that check silently stops working. diffInDays() returns -31.4 instead of 31, so an invoice can be 31 days overdue and the late fee never fires. No test fails, and nothing shows up in the logs.

    That’s the real risk with Laravel 12. The official upgrade guide puts the framework work at around five minutes, and for a small, well-tested app that can be close. But several of the changes don’t throw an error. They change a number, a lookup, or a dependency, and you find out later from a customer or a report that doesn’t add up.

    This guide goes through every change in the official guide, plus the Carbon 3 changes that come with it. For each one, I explain how it shows up in a real application and what to change.

    If you’re still on Laravel 10, read our Laravel 10 to 12 upgrade guide first. It covers the upgrade process step by step, including the Laravel 11 stage. This post is the detailed reference for the Laravel 12 step.

    What You Don’t Need to Change

    A lot of Laravel 12 tutorials mix in changes that actually arrived in Laravel 11. That sends people off to fix things that were never broken, and sometimes they break working code in the process.

    If you’re coming from Laravel 11, the PHP requirement stays the same. Laravel 12 needs PHP 8.2 or newer, just like Laravel 11, so you don’t need a server change for this step.

    Middleware didn’t change either. If your application still uses app/Http/Kernel.php, keep it. The same goes for your config/ folder. You don’t need to compare it with a fresh install or delete entries, because the changes to the default project files are optional.

    Queues work the same way they did in Laravel 11. If jobs fail right after the upgrade, a worker is usually still running old code. I cover that near the end.

    Find What Affects Your App in Two Minutes

    The official guide lists every possible breaking change, but most of them won’t touch your application. Before reading the rest of this post, run these searches from your project root. They tell you which sections matter for you.

    # Carbon 3: date differences, timestamps, and removed methods

    grep -rnE "diffIn|createFromTimestamp|formatLocalized|setTestNow|isSame[A-Za-z]*\(\)" app/ resources/views/ routes/ database/ tests/

    # UUIDs, table lookups, and smaller API changes

    grep -rnE "HasUuids|HasVersion7Uuids|getTableListing|getTables|mergeIfMissing|Concurrency::run|DatabaseTokenRepository" app/ database/

    # Validation rules that may receive SVG files

    grep -rnE "'image'|\|image|image\||File::image" app/

    # Nullable class dependencies with a null default

    grep -rnE '\?[A-Z]\w* \$\w+ = null' app/

    # Does your app define a “local” disk?

    grep -n "'local'" config/filesystems.php

    # Duplicate route names (needs jq installed)

    php artisan route:list --json | jq -r '.[].name // empty' | sort | uniq -d

    Each match is a place to review. Some will turn out to be fine. Keep the output open while you read, because the sections below explain what to look for in each one.

    Package Errors That Aren’t Your Code

    Composer is the loud part of the upgrade. Set laravel/framework to ^12.0 and phpunit/phpunit to ^11.0 (or Pest to ^3.0). Composer then either installs everything or tells you what’s blocking it. Our 10 to 12 guide explains how to work through those blockers with composer why-not.

    The confusing case is when Composer succeeds, and the app then crashes with an error like this:

    Illuminate\Database\Schema\Blueprint::__construct(): Argument #1 ($connection)

    must be of type Illuminate\Database\Connection, string given

    It looks like your code broke, but it usually comes from a package. In Laravel 12, some low-level database classes need a database connection passed in when they’re created, and Grammar::setConnection() was removed (upgrade guide). Packages that build these classes themselves break until their maintainers update them. In practice, that means database drivers, schema tools, and some multi-tenancy packages.

    If the stack trace mentions Blueprint, Grammar, or setConnection(), look for a newer release of the package that created the object. Don’t patch the file inside vendor/. Your fix disappears on the next composer install, and the real problem is still there.

    Carbon 3: Type Errors and Wrong Date Math

    Laravel 12 drops Carbon 2 and needs Carbon 3. Carbon is the library behind now(), today(), and every date on your models.

    Laravel rates this as a low-impact change. For many applications, I’d put it at the top of the list. Dates drive trials, renewals, due dates, and reports, and a wrong date calculation can quietly write bad data for weeks. Cleaning that up later is far more work than checking the code now.

    Date differences now return signed decimals

    In Carbon 2, diffInDays(), diffInHours(), and the other diffIn* methods returned a positive whole number. In Carbon 3, they return a decimal, and the result is negative when the date you compare against is in the past. That’s the check behind the Net 30 late fee:

    // Invoice was due 31 days ago
    $daysOverdue = now()->diffInDays($invoice->due_date);
    
    // Carbon 2: 31
    // Carbon 3: -31.4
    
    if ($daysOverdue > 30) {
        $invoice->applyLateFee(); // never runs on Carbon 3
    }

    Exact comparisons fail too. A reminder that checks $daysLeft == 3 stops sending, because the value is now something like 3.25.

    If your business logic expects the old behavior, say so in the code. Pass true as the second argument to get an absolute value, and cast the result to an integer:

    $daysOverdue = (int) now()->diffInDays($invoice->due_date, true);

    Don’t apply that fix everywhere by search and replace. Some code may actually want the sign, for example to tell whether a date is in the past. Read each match and decide what the business rule needs.

    Strings are no longer accepted as numbers

    Values from .env arrive as strings. Carbon 2 accepted them where it expected a number. Carbon 3 throws a type error instead:

    Carbon\Carbon::rawAddUnit(): Argument #3 ($value) must be of type int|float, string given

    The fix is to cast the value before you pass it in:

    // TRIAL_DAYS=14 in .env

    now()->addDays((int) config('billing.trial_days'));

    Comparison methods like gt() and eq() are stricter too. Passing null or false now throws an error, where Carbon 2 returned a result that didn’t mean much.

    Timestamps now default to UTC

    Carbon::createFromTimestamp() used to create the date in your application’s timezone. Carbon 3 uses UTC. If your app.timezone is already UTC, nothing changes for you.

    If it’s something like America/New_York, every date you build from a Unix timestamp moves by several hours. Payment webhooks are the usual place to see this, because they send timestamps. Pass the timezone explicitly to keep the old behavior:

    Carbon::createFromTimestamp($event->created, config('app.timezone'));

    Two smaller Carbon changes

    The isSameDay(), isSameMonth(), and other isSame* methods now need a date to compare against. If your code called them with no argument to mean “now”, use isToday() or pass ‘now’. And formatLocalized() has been removed, so replace it with isoFormat().

    Work through the diffIn matches starting with money and deadlines: trial periods, billing cycles, late fees, due dates, reminders, and reports.

    UUIDs Look Different (HasUuids Now Uses UUIDv7)

    This is the only change Laravel rates as medium impact. Models that use the HasUuids trait now generate UUIDv7 values. Your existing IDs don’t change, but new ones look different.

    UUIDv7 values are ordered by creation time, which is good for database indexes. Two side effects are worth knowing. First, a partner API, mobile app, or validation rule that expects version 4 UUIDs may start rejecting new records. Second, anyone who sees the ID can tell roughly when the record was created. If the IDs appear in public URLs and that timing is sensitive, factor that in.

    If another system needs version 4 UUIDs, switch those models to the HasVersion4Uuids trait:

    use Illuminate\Database\Eloquent\Concerns\HasVersion4Uuids as HasUuids;

    The HasVersion7Uuids trait has been removed. If your code used it, the app fails with Trait “Illuminate\Database\Eloquent\Concerns\HasVersion7Uuids” not found. Replace it with HasUuids, which now does the same thing.

    Table Lookups Return Different Results

    This change only matters if your code reads the database structure. Custom Artisan commands, multi-tenant setups, backup scripts, and admin tools are the usual places. When it breaks, it breaks quietly: a check returns false, and the code carries on.

    Schema::getTables(), getViews(), and getTypes() now return results from every schema your database user can see (upgrade guide). Schema::getTableListing() now includes the schema in each name, so you get main.users instead of users. Code like this stops matching without any error:

    // Silently returns false in Laravel 12
    in_array('audit_logs', Schema::getTableListing());
    
    For existence checks, use the method built for the job:
    Schema::hasTable('audit_logs');
    
    If you really need the plain list, ask for it:
    Schema::getTableListing(schema: 'main', schemaQualified: false);

    Pass the schema argument anywhere your code should only see one schema. This matters most for PostgreSQL applications that use a separate schema for each tenant, where a command could otherwise start touching other tenants’ tables.

    SVG Uploads and the Local Disk

    Since Laravel 12, the image validation rule rejects SVG files unless you allow them. The reason is a good one: an SVG is a document that can carry scripts. If your users upload logos, their uploads start failing with “The logo field must be an image” on the day you deploy.

    Allow SVGs only where you also clean the file, for example with a sanitizer package such as enshrined/svg-sanitize (upgrade guide):

    'logo' => 'required|image:allow_svg',

    The storage change affects fewer apps. If your config/filesystems.php doesn’t define a local disk, Laravel now uses storage/app/private as its root instead of storage/app (upgrade guide). New files go to the new folder, and files saved before the upgrade seem to disappear.

    Most applications upgraded from Laravel 10 still have the full config file, so they aren’t affected. If yours doesn’t define the disk, add it with the root you expect:

    'local' => [
        'driver' => 'local',
        'root' => storage_path('app'),
    ],

    A Class Gets null Instead of a Dependency

    This one looks like a random bug. A service that has worked for years suddenly fails with an error like this:

    Call to a member function charge() on null

    The cause is a small change in the service container. It now respects default values when it builds a class (upgrade guide). Take this constructor:

    class CheckoutService
    {
        public function __construct(public ?PaymentGateway $gateway = null) {}
    }

    In Laravel 11, the container created a PaymentGateway and passed it in. In Laravel 12, it uses the default value, so $gateway is null.

    Go through the = null matches from the search above, and focus on classes Laravel builds for you: controllers, jobs, listeners, and services. If the dependency is always needed, remove the ? and the = null. Keep the default only where null is a valid value.

    Smaller Changes That Quietly Change Behavior

    These affect only a few applications, but none of them throws an error, so they’re easy to miss.

    If two routes share a name, route(‘name’) now returns the first one registered instead of the last. Cached routes already worked this way, so production may not change, but your local and test environments can. The simple fix is to give every route a unique name.

    $request->mergeIfMissing() now treats a key like ‘user.last_name’ as nested data. Before, it created one top-level key with a dot in its name.

    Concurrency::run() now keeps your array keys. If you pass [‘task-1’ => …, ‘task-2’ => …], the results come back with those same keys instead of numbered ones.

    The last one is easy to overlook. If your code creates DatabaseTokenRepository directly, its $expires value is now in seconds, not minutes. Passing 60 now gives users a one-minute password reset link. The standard expire setting in config/auth.php is still in minutes and isn’t affected.

    Test Failures That Point to Real Bugs

    After the upgrade, some test failures are only noise, and some are the most useful warning you’ll get.

    The noise comes mostly from PHPUnit 11, which Laravel 12 requires. It deprecates doc-comment annotations like /** @test */ and @dataProvider, because PHPUnit 12 removes them. Replace them with attributes such as #[Test] and #[DataProvider(‘providerMethod’)]. Rector can do most of this for you.

    Frozen time is a subtler change. Carbon::setTestNow($date) now stores a copy of the date. If your test changes $date afterwards, the frozen time no longer moves with it. Call setTestNow() again after the change, or use Laravel’s helpers like $this->travel(3)->days().

    The useful warnings are date assertions that now get negative or decimal numbers. Don’t update the expected values to make them pass. Fix the code using the Carbon section above, because the new output is often the bug your customers would have found.

    Before You Deploy

    Two things are easy to forget. First, queue workers, Horizon, and Octane keep the old code in memory until they restart, so restart them as part of the deploy. Our 10 to 12 guide covers how to do that safely.

    Second, check the changes from this post against a copy of production data. A test database won’t show most of them. In particular, confirm that:

    • Date calculations for trials, renewals, late fees, and reports give the same results as before
    • Times created from webhook timestamps show in the right timezone
    • New records get IDs that connected systems accept
    • SVG uploads work where you expect them to, and older files can still be found
    • Password reset links last as long as they should

    When This Isn’t a Five-Minute Upgrade

    The five-minute estimate assumes you’re already on Laravel 11 and your packages already support Laravel 12. Many applications don’t start there.

    It takes longer when a key package hasn’t released Laravel 12 support and you need to replace it. It also takes longer when a lot of date logic is tied to money. The same goes for apps with no tests around the areas this post covers. We once took over an app still on Laravel 5.7. When you’re that far behind, you’re planning a project with its own timeline.

    In those cases, work on a separate branch, run the searches above, and budget real time for checking results against production data.

    The Quiet Changes Are the Real Work

    Laravel 12 changes very little in the framework itself. Composer conflicts and type errors are the easy part, because they stop you immediately. The real work is the quiet changes: date differences that alter billing logic, table lookups that stop matching, dependencies that become null, and reset links that expire too soon.

    These issues matter for a limited window. Laravel 12 stopped receiving bug fixes on August 13, 2026. Security fixes end on February 24, 2027. Once you are stable on 12, plan the move to Laravel 13 (which requires PHP 8.3 or newer).

    Start today by running the searches at the top of this guide on your own codebase. Begin with the diffIn matches. Billing and deadline logic are the areas that usually cost the most when they go wrong.

    Not sure what this upgrade will actually take on your application?
    Send us your current Laravel version and your composer.json.

    We’ll reply within 3 business days with:

    • Which packages will block the upgrade
    • The silent breaking changes most likely to affect your app
    • A realistic effort estimate

    No sales call required. You get a clear technical assessment.

    Get your free Laravel 12 compatibility report

    FAQ

    How long does a Laravel 11 to 12 upgrade take? Laravel’s upgrade guide gives five minutes as the estimate for framework changes. Small apps with good tests can come close. Apps with outdated packages or date logic tied to billing take longer, mostly because the Carbon 3 changes need checking against real data.

    Does Laravel 12 need a new PHP version? Not if you’re coming from Laravel 11. Laravel 12 supports PHP 8.2 to 8.5, and Laravel 11 already needed 8.2. Laravel 13 is different: it needs PHP 8.3 or newer (release notes).

    Why does diffInDays() return a negative number after upgrading? Laravel 12 requires Carbon 3, where the diffIn* methods return signed decimals. A date in the past gives a negative result. Pass true as the second argument for an absolute value, and cast to int if you need a whole number (Carbon migration guide).

    Why are SVG uploads failing after the Laravel 12 upgrade? The image rule now rejects SVGs unless you allow them, because SVG files can contain scripts. Use image:allow_svg only on fields where you also sanitize the uploaded file.

    Is Laravel 12 still supported? Only for security fixes. Bug fixes ended on August 13, 2026, and security fixes end on February 24, 2027 (release notes).

    Do I need to update my config files for Laravel 12? No. The changes to the default project files are optional. The one exception is storage: if config/filesystems.php has no local disk, add one, or files start going to storage/app/private.

    Larastaff

    Larastaff

    Related Articles

    Leave a Comment

    Your email address will not be published. Required fields are marked *