August 4, 2026

Laravel 13: What Actually Breaks When You Upgrade (and What Doesn't)

Photo of Marco Orta Marco Orta | 13 mins read
Compartir
Illustration of a cracking red security shield over Laravel's signature red background, with fragments of code falling away
Table of Contents

    On August 13, 2026, Laravel 12 stops receiving bug fixes. After that date only security patches remain, and those run out in February 2027. If you have a production app on 12, the clock is already running.

    The good news is that Laravel 13 really is a small upgrade. The official docs estimate 10 minutes and say most applications can upgrade “without changing much application code.” That is true.

    The bad news is the nuance almost nobody is covering: minimal breaking changes is not zero breaking changes. The official upgrade guide flags two high-impact changes, two medium-impact ones, and roughly fifteen low-impact ones. And if your application caches PHP objects, uses upsert on MySQL, or excludes the CSRF middleware in tests, one of them is going to bite.

    This article is the complete list, with the concrete code for each change. If what you need is the general upgrade procedure — backup, branch, composer update, verification — that lives in the guide to upgrading Laravel to the latest version; here we go straight to what breaks.

    First: where you are and how urgent this is

    This is the official support table, not the one making the rounds:

    VersionSupported PHPReleasedBug fixes untilSecurity until
    Laravel 138.3 – 8.5Mar 17, 2026Q3 2027Mar 17, 2028
    Laravel 128.2 – 8.5Feb 24, 2025Aug 13, 2026Feb 24, 2027
    Laravel 118.2 – 8.4Mar 12, 2024Sep 3, 2025Mar 12, 2026 ❌
    Laravel 108.1 – 8.3Feb 14, 2023Aug 6, 2024Feb 4, 2025 ❌

    Two things worth reading carefully in that table:

    • Laravel 11 has received no security patches since March 12, 2026. If you’re there, you aren’t “a little behind” — you’re uncovered.
    • Laravel 12 is not LTS. Laravel stopped shipping LTS releases years ago; the current policy is uniform across every major — 18 months of bug fixes and 2 years of security.
    flowchart TB
        A["Which version are you on?"] --> B["Laravel 12"]
        A --> C["Laravel 11"]
        A --> D["Laravel 10 or older"]
        B --> B1["✅ Jump straight to 13<br/>Read this list and do it"]
        C --> C1["⚠️ No security patches<br/>since Mar 12, 2026"]
        C1 --> C2["Go 11 → 12 → 13,<br/>one major at a time"]
        D --> D1["🚨 Fully end of life"]
        D1 --> C2

    Entry requirement: PHP 8.3

    Laravel 13 requires PHP 8.3 minimum and supports up to 8.5. No more, no less: if you read somewhere that “in practice it needs 8.4,” that’s wrong — 8.3 works.

    php -v          # you need 8.3.0 or higher
    composer -V     # and an up-to-date Composer 2.x
    

    If you’re coming from Laravel 12 on PHP 8.2, this is the only real infrastructure work in the upgrade. Do it before touching composer.json, not at the same time, so that if something breaks you know what broke it.


    High impact

    There are two. Both affect practically every application.

    1. Dependencies in composer.json

    No surprises, but there are more packages than people remember:

    {
      "require": {
        "php": "^8.3",
        "laravel/framework": "^13.0",
        "laravel/tinker": "^3.0"
      },
      "require-dev": {
        "laravel/boost": "^2.0",
        "phpunit/phpunit": "^12.0",
        "pestphp/pest": "^4.0"
      }
    }
    

    The two most commonly forgotten are laravel/tinker to ^3.0 and the jump to PHPUnit ^12.0. If you use Pest, you need ^4.0.

    And if you installed the global Laravel installer:

    composer global update laravel/installer
    

    With Herd, updating Herd is enough.

    2. The CSRF middleware changes name and behavior

    This is the significant change in the release, and the one most likely to quietly break your tests.

    VerifyCsrfToken is renamed to PreventRequestForgery, and it isn’t just a rename: it now performs origin verification by reading the browser’s Sec-Fetch-Site header, on top of the usual token validation.

    use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
    use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
    
    // Laravel <= 12.x
    ->withoutMiddleware([VerifyCsrfToken::class]);
    
    // Laravel >= 13.x
    ->withoutMiddleware([PreventRequestForgery::class]);
    

    VerifyCsrfToken and ValidateCsrfToken still exist as deprecated aliases, so your app won’t explode on day one. But:

    • Referencing them directly stops being a good idea, especially when excluding middleware in tests or route definitions.
    • The middleware configuration API now exposes preventRequestForgery(...).

    Where this really bites is test suites that call withoutMiddleware([VerifyCsrfToken::class]) expecting to disable the protection: because the alias points elsewhere, the exclusion may not apply the way you assumed. Grep for it before upgrading:

    grep -rn "VerifyCsrfToken\|ValidateCsrfToken" app/ tests/ routes/ bootstrap/
    

    Since you’re hardening request forgery protection anyway, this is a good moment to check which security headers your domain is actually returning in production:


    Medium impact

    3. serializable_classes in the cache config

    The default cache configuration now includes serializable_classes => false. It’s a hardening measure against deserialization gadget chain attacks: if your APP_KEY leaks, without this protection an attacker could inject serialized PHP objects into the cache and chain magic methods until they achieve code execution.

    The price is that if your application deliberately stores PHP objects in cache, it stops working unless you explicitly declare which classes may be unserialized:

    // config/cache.php
    'serializable_classes' => [
        App\Data\CachedDashboardStats::class,
        App\Support\CachedPricingSnapshot::class,
    ],
    

    If you were caching arbitrary objects, you have two ways out: an explicit allow-list like the one above, or migrating to object-free payloads (plain arrays). The second is more work and the better idea.

    Watch out for one detail: these errors don’t show up at deploy time, they show up the first time someone reads a cache entry that contained an object. Which means in production, a while later. Flush the cache on deploy and cover it with a test.

    4. upsert with MySQL or MariaDB

    Laravel now validates that uniqueBy isn’t empty, and throws InvalidArgumentException instead of generating invalid SQL:

    // Laravel >= 13.x: this now blows up with InvalidArgumentException
    DB::table('stats')->upsert($rows, [], ['views']);
    

    The curious part is that the MySQL and MariaDB drivers ignore the value of uniqueBy — they always use the table’s primary and unique indexes to detect existing records. The validation applies regardless. So if your code passed an empty array because “MySQL ignores it anyway,” you now have to pass the real columns.


    Low impact (the ones that will actually bite)

    This is where the real value is, because nobody lists these and they’re the ones that surface three weeks after the deploy.

    The default prefixes move from underscore to hyphen:

    // Laravel <= 12.x
    Str::slug((string) env('APP_NAME', 'laravel'), '_').'_cache_';
    Str::slug((string) env('APP_NAME', 'laravel'), '_').'_database_';
    Str::slug((string) env('APP_NAME', 'laravel'), '_').'_session';
    
    // Laravel >= 13.x
    Str::slug((string) env('APP_NAME', 'laravel')).'-cache-';
    Str::slug((string) env('APP_NAME', 'laravel')).'-database-';
    Str::slug((string) env('APP_NAME', 'laravel')).'-session';
    

    It only affects you if you don’t have those values defined in your configuration and rely on the framework fallback. The symptom is ugly and confusing: the entire cache is invalidated at once and every active session is logged out the moment you deploy, because the keys and the cookie name changed.

    To avoid it, pin the values explicitly before upgrading:

    CACHE_PREFIX=myapp_cache_
    REDIS_PREFIX=myapp_database_
    SESSION_COOKIE=myapp_session
    

    6. JobAttempted now exposes the exception

    The event swaps a boolean property for the exception object:

    // Laravel <= 12.x
    $event->exceptionOccurred;   // bool
    
    // Laravel >= 13.x
    $event->exception;           // Throwable|null
    

    It’s a change for the better — now you know what failed, not just that it failed — but any listener reading exceptionOccurred stops working. And since it’s a boolean read inside an if, the failure is silent: null is falsy, so your listener simply stops reacting.

    7. QueueBusy: $connection becomes $connectionName

    A pure rename, for consistency with the rest of the queue events. If you monitor queues with your own listener, update it.

    grep -rn "QueueBusy" app/
    

    8. Container::call respects nullable defaults

    $container->call(function (?Carbon $date = null) {
        return $date;
    });
    
    // Laravel <= 12.x: a Carbon instance
    // Laravel >= 13.x: null
    

    It now matches the constructor injection behavior introduced in Laravel 12. If you had code relying on the container resolving the class despite the = null, the result changes without warning.

    9. Domain route precedence

    Routes with an explicit domain are now evaluated before routes without one. This fixes the catch-all subdomain case, which previously depended on registration order.

    If you do multi-tenancy by subdomain, or you declare domain routes after general ones, review your matching. It’s one of the few changes on this list that can alter which controller handles a request.

    10. DELETE with JOIN, ORDER BY, and LIMIT on MySQL

    Previously, in a delete with a join, the ORDER BY and LIMIT clauses were silently ignored. Now they’re compiled and sent to the database.

    In practice: code you believed was bounded (->limit(100)) but was actually deleting without a limit can now throw QueryException, because MySQL and MariaDB don’t support that syntax in joined deletes.

    It’s an awkward change but a healthy one — it was lying to you. If you run into it, inspect the generated SQL before touching anything:

    11. Polymorphic pivot table names

    When inferring the table name for a polymorphic pivot model with a custom class, Laravel now pluralizes. If you relied on the singular inferred name, declare the table explicitly on the pivot:

    class Taggable extends MorphPivot
    {
        protected $table = 'taggable';   // this used to be inferred
    }
    

    12. Collection serialization restores eager-loaded relations

    When a model collection is serialized and restored — the usual case in queued jobs — relations loaded with with() are now present again after deserialization.

    That’s usually what you want. But if your job counted on the relations not being there (to force a fresh reload from the database, say), it now works with potentially stale data. That bug is especially treacherous because nothing fails: it just processes old data.

    13. Str factories reset between tests

    Laravel now clears custom Str factories during test teardown. If you defined a deterministic UUID or ULID generator once — in a setUpBeforeClass or a global helper — and expected it to persist across methods, you now have to define it in each test or in setUp.

    Symptom: tests that pass in isolation and fail in the suite.

    14. extend callbacks are bound to the manager

    Custom driver closures registered via extend now have the manager as $this, not whatever was there before. If you used $this inside the closure expecting the service provider, move it into a capture:

    // Before: $this was the service provider
    Cache::extend('mydriver', function ($app) {
        return $this->createStore($app);      // no longer works
    });
    
    // Now
    $provider = $this;
    Cache::extend('mydriver', function ($app) use ($provider) {
        return $provider->createStore($app);
    });
    

    15. Bootstrap 3 pagination view names

    // Laravel <= 12.x
    pagination::default
    pagination::simple-default
    
    // Laravel >= 13.x
    pagination::bootstrap-3
    pagination::simple-bootstrap-3
    

    Only relevant if you reference those view names directly. The old names were confusing — default wasn’t the actual default — and now they’re explicit.

    16. Js::from stops escaping Unicode

    It now uses JSON_UNESCAPED_UNICODE by default. If you have tests or output comparisons expecting è instead of è, update the expectations. Any accented character triggers this.

    17. The PHP 8.5 polyfill and global helpers

    This is the easiest one to miss. Laravel 13 adds a dependency on symfony/polyfill-php85, which on PHP below 8.5 defines global functions such as array_first() and array_last() if they don’t already exist.

    The conflict: the historical array_first() from laravel/helpers accepted a callback to return the first element matching a condition. The polyfilled one only returns the array’s first element. Same apparent signature, different behavior.

    If you still carry laravel/helpers or your own global helpers with those names, always use Arr:

    use Illuminate\Support\Arr;
    
    Arr::first($array, fn ($value) => $value->active);
    

    Very low impact: only if you maintain packages

    If you implement framework contracts yourself, there are new methods to add. If you only consume Laravel, skip this section entirely.

    ContractMethod to add
    Illuminate\Contracts\Cache\Storetouch($key, $seconds)
    Illuminate\Contracts\Bus\DispatcherdispatchAfterResponse($command, $handler = null)
    Illuminate\Contracts\Routing\ResponseFactoryeventStream(...)
    Illuminate\Contracts\Auth\MustVerifyEmailmarkEmailAsUnverified()
    Illuminate\Contracts\Queue\QueuependingSize, delayedSize, reservedSize, creationTimeOfOldestPendingJob

    On top of that, instantiating a model while that model is still booting now throws LogicException:

    protected static function boot()
    {
        parent::boot();
    
        (new static())->getTable();   // LogicException on Laravel 13
    }
    

    And the HTTP client’s throw and throwIf signatures now declare their callbacks explicitly, which only matters if you override those methods in a custom response class.

    What does NOT break, whatever you may have read

    A couple of false claims are circulating about this upgrade:

    • app/Http/Kernel.php does not disappear in Laravel 13. That file was removed in Laravel 11, with the new application structure. If you’re coming from 12 you no longer have it.
    • Laravel 13 does not require PHP 8.4. The minimum is 8.3 and the supported range goes up to 8.5.
    • VerifyCsrfToken is not removed. It remains a deprecated alias. Migrating it is wise, but it won’t take your deploy down.
    • There are no “zero breaking changes.” The documentation itself says minimal, and flags two high-impact changes. The gap between “minimal” and “zero” is exactly this article.

    The upgrade, in practice

    With the full list in front of you, the procedure is short:

    # 1. New branch, always
    git checkout -b upgrade/laravel-13
    
    # 2. PHP 8.3+ before anything else
    php -v
    
    # 3. Pin cache and session prefixes so you don't invalidate everything
    #    (CACHE_PREFIX, REDIS_PREFIX, SESSION_COOKIE in your .env)
    
    # 4. Grep for what you know changes
    grep -rn "VerifyCsrfToken\|ValidateCsrfToken" app/ tests/ routes/ bootstrap/
    grep -rn "exceptionOccurred\|QueueBusy" app/
    grep -rn "array_first\|array_last" app/
    
    # 5. Update dependencies
    composer update --with-all-dependencies
    
    # 6. Clear everything
    php artisan optimize:clear
    
    # 7. Tests
    php artisan test
    

    Laravel also offers an assisted route: if you have Laravel Boost ^2.0 installed, the /upgrade-laravel-v13 command walks the upgrade from Claude Code, Cursor, OpenCode, Gemini, or VS Code. And for large projects, Shift is still the paid option that automates the bulk of it.

    One last step almost nobody takes, and it saves surprises: diff your configuration files against laravel/laravel on the 13.x branch. Many changes on this list — serializable_classes being the obvious one — live in config/, not in the framework, and don’t arrive on their own with composer update.

    Checklist before you merge

    • PHP 8.3 or higher on local, CI, and production
    • laravel/framework ^13.0, laravel/tinker ^3.0, phpunit ^12.0 or pest ^4.0
    • Zero references to VerifyCsrfToken in tests and routes
    • CACHE_PREFIX, REDIS_PREFIX, and SESSION_COOKIE pinned explicitly
    • config/cache.php reviewed if you cache PHP objects
    • Every upsert() call passes a non-empty uniqueBy
    • JobAttempted and QueueBusy listeners updated
    • Domain routes reviewed if you use subdomains
    • Test suite green as a whole, not just file by file
    • Cache flushed on deploy

    Conclusion

    Laravel 13 is an honest upgrade: 10 minutes for most apps, and the new capabilities — the first-party AI SDK, JSON:API resources, Queue::route(), PHP attributes for middleware and jobs, Cache::touch(), vector search with whereVectorSimilarTo() — arrive without demanding a rewrite in exchange.

    But “minimal” is not “zero,” and the changes that actually hurt aren’t the two high-impact ones — those are documented and surface immediately — but the low-impact ones that fail silently: the cache that invalidates wholesale, the listener that stops reacting, the job that processes stale data, the test that only fails in the suite.

    The date is what matters: August 13, 2026. After that, every bug you find in Laravel 12 is yours.

    Compartir

    Search

    Tags

    Tutorial PHP Laravel Web Development JavaScript AI Best Practices Laravel 13 Security Tools Claude SEO Regular Expressions Text Manipulation Frontend