4 de agosto de 2026

Laravel 13: qué se rompe de verdad al actualizar (y qué no)

Foto de Marco Orta Marco Orta | 13 min de lectura
Compartir
Ilustración de un escudo de seguridad rojo agrietándose sobre el fondo rojo característico de Laravel, con fragmentos de código cayendo
Tabla de Contenidos

    El 13 de agosto de 2026 Laravel 12 deja de recibir bug fixes. A partir de esa fecha solo quedan parches de seguridad, hasta febrero de 2027. Si tienes una app en producción sobre la 12, el reloj ya está corriendo.

    La buena noticia es que Laravel 13 es, de verdad, un upgrade pequeño. La documentación oficial estima 10 minutos y dice que la mayoría de aplicaciones pueden actualizar «sin cambiar mucho código de aplicación». Eso es cierto.

    La mala es el matiz que casi nadie está contando: minimal breaking changes no es zero breaking changes. La guía oficial de upgrade marca dos cambios de impacto alto, dos de impacto medio y unos quince de impacto bajo. Y si tu aplicación cachea objetos PHP, usa upsert en MySQL o excluye el middleware CSRF en los tests, uno de ellos te va a morder.

    Este artículo es la lista completa, en español, con el código concreto de cada cambio. Si lo que buscas es el procedimiento general del upgrade —respaldo, rama, composer update, verificación— eso está en la guía para actualizar Laravel a la última versión; aquí vamos directos a lo que se rompe.

    Primero: dónde estás y cuánto corre prisa

    Esta es la tabla de soporte oficial, no la que circula por ahí:

    VersiónPHP soportadoPublicadaBug fixes hastaSeguridad hasta
    Laravel 138.3 – 8.517 mar 2026Q3 202717 mar 2028
    Laravel 128.2 – 8.524 feb 202513 ago 202624 feb 2027
    Laravel 118.2 – 8.412 mar 20243 sep 202512 mar 2026 ❌
    Laravel 108.1 – 8.314 feb 20236 ago 20244 feb 2025 ❌

    Dos cosas que conviene leer bien de esa tabla:

    • Laravel 11 ya no recibe parches de seguridad desde el 12 de marzo de 2026. Si estás ahí, no estás «un poco atrasado»: estás sin cobertura.
    • Laravel 12 no es LTS. Laravel dejó de publicar versiones LTS hace años; la política actual es uniforme para todas las mayores — 18 meses de bug fixes y 2 años de seguridad.
    flowchart TB
        A["¿En qué versión estás hoy?"] --> B["Laravel 12"]
        A --> C["Laravel 11"]
        A --> D["Laravel 10 o anterior"]
        B --> B1["✅ Salto directo a 13<br/>Lee esta lista y hazlo"]
        C --> C1["⚠️ Sin parches de seguridad<br/>desde el 12 mar 2026"]
        C1 --> C2["Sube 11 → 12 → 13,<br/>una mayor a la vez"]
        D --> D1["🚨 End of life total"]
        D1 --> C2

    Requisito de entrada: PHP 8.3

    Laravel 13 exige PHP 8.3 como mínimo y soporta hasta 8.5. Ni más ni menos: si lees por ahí que «en la práctica pide 8.4», es falso — 8.3 funciona.

    php -v          # necesitas 8.3.0 o superior
    composer -V     # y Composer 2.x actualizado
    

    Si vienes de Laravel 12 sobre PHP 8.2, este es el único trabajo de infraestructura real del upgrade. Súbelo antes de tocar composer.json, no a la vez, para que si algo falla sepas qué lo rompió.


    Impacto alto

    Son dos. Ambos afectan a prácticamente cualquier aplicación.

    1. Dependencias en composer.json

    Sin sorpresas, pero hay más paquetes de los que la gente recuerda:

    {
      "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"
      }
    }
    

    Los dos que más se olvidan son laravel/tinker a ^3.0 y el salto de PHPUnit a ^12.0. Si usas Pest, necesitas ^4.0.

    Y si instalaste el instalador global de Laravel:

    composer global update laravel/installer
    

    Con Herd, basta con actualizar Herd.

    2. El middleware CSRF cambia de nombre y de comportamiento

    Este es el cambio importante del release, y el que más silenciosamente puede romperte los tests.

    VerifyCsrfToken pasa a llamarse PreventRequestForgery, y no es solo un rename: ahora hace verificación de origen leyendo la cabecera Sec-Fetch-Site del navegador, además de la validación de token de siempre.

    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 y ValidateCsrfToken siguen existiendo como alias deprecados, así que tu app no va a explotar el día 1. Pero:

    • Deja de ser buena idea referenciarlos directamente, sobre todo al excluir middleware en tests o en definiciones de rutas.
    • La API de configuración de middleware ahora expone preventRequestForgery(...).

    El sitio donde esto muerde de verdad es en suites de tests que hacen withoutMiddleware([VerifyCsrfToken::class]) esperando desactivar la protección: como el alias apunta a otra clase, la exclusión puede no aplicarse como creías. Búscalo con un grep antes de actualizar:

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

    Ya que estás endureciendo la protección contra peticiones falsificadas, es buen momento para revisar qué cabeceras de seguridad está devolviendo tu dominio en producción:


    Impacto medio

    3. serializable_classes en la configuración de caché

    La configuración por defecto de cache ahora incluye serializable_classes => false. Es un endurecimiento contra ataques de gadget chain por deserialización: si a alguien se le filtra tu APP_KEY, sin esta protección podría inyectar objetos PHP serializados en la caché y encadenar métodos mágicos hasta ejecutar código.

    El precio es que si tu aplicación guarda objetos PHP en caché a propósito, deja de funcionar salvo que declares explícitamente qué clases pueden deserializarse:

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

    Si estabas cacheando objetos arbitrarios, tienes dos salidas: una lista blanca explícita como la de arriba, o migrar a payloads sin objetos (arrays planos). La segunda es más trabajo y mejor idea.

    Ojo con un detalle: los errores de esto no aparecen al desplegar, aparecen la primera vez que alguien lee una entrada de caché que contenía un objeto. Es decir, en producción, un rato después. Vacía la caché al desplegar y cúbrelo con un test.

    4. upsert con MySQL o MariaDB

    Laravel ahora valida que uniqueBy no venga vacío, y lanza InvalidArgumentException en vez de generar SQL inválido:

    // Laravel >= 13.x: esto ahora revienta con InvalidArgumentException
    DB::table('stats')->upsert($rows, [], ['views']);
    

    Lo curioso es que los drivers de MySQL y MariaDB ignoran el valor de uniqueBy — usan siempre los índices primarios y únicos de la tabla para detectar duplicados. La validación se aplica igual. Así que si tu código pasaba un array vacío porque «total, MySQL lo ignora», ahora tienes que pasar las columnas de verdad.


    Impacto bajo (los que sí te van a morder)

    Aquí es donde está el valor real, porque estos no los lista nadie y son los que aparecen tres semanas después del deploy.

    Los prefijos por defecto pasan de guion bajo a guion:

    // 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';
    

    Solo te afecta si no tienes definidos esos valores en tu configuración y dependes del fallback del framework. El síntoma es feo y confuso: toda la caché se invalida de golpe y todas las sesiones activas se cierran en el momento del deploy, porque las claves y el nombre de la cookie cambiaron.

    Para evitarlo, fija los valores explícitamente antes de actualizar:

    CACHE_PREFIX=miapp_cache_
    REDIS_PREFIX=miapp_database_
    SESSION_COOKIE=miapp_session
    

    6. JobAttempted ahora expone la excepción

    El evento cambia una propiedad booleana por el objeto de excepción:

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

    Es un cambio a mejor —ahora sabes qué falló, no solo que falló— pero cualquier listener que leyera exceptionOccurred deja de funcionar. Y como es un booleano leído en un if, el fallo es silencioso: null es falsy, así que tu listener simplemente deja de reaccionar.

    7. QueueBusy: $connection pasa a $connectionName

    Rename puro, por coherencia con el resto de eventos de cola. Si monitorizas colas con un listener propio, actualízalo.

    grep -rn "QueueBusy" app/
    

    8. Container::call respeta los defaults nullable

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

    Ahora coincide con el comportamiento de la inyección por constructor que llegó en Laravel 12. Si tenías código apoyándose en que el contenedor resolvía la clase pese al = null, cambia de resultado sin avisar.

    9. Precedencia de rutas con dominio

    Las rutas con dominio explícito ahora se evalúan antes que las rutas sin dominio. Esto arregla el caso de las rutas catch-all de subdominio, que antes dependían del orden de registro.

    Si tienes multi-tenancy por subdominio, o rutas de dominio declaradas después de rutas generales, revisa el matching. Es de los pocos cambios de esta lista que puede alterar qué controlador atiende una petición.

    10. DELETE con JOIN, ORDER BY y LIMIT en MySQL

    Antes, en un delete con join, las cláusulas ORDER BY y LIMIT se ignoraban en silencio. Ahora se compilan y se envían a la base de datos.

    Traducción práctica: código que creías acotado (->limit(100)) y que en realidad estaba borrando sin límite, ahora puede lanzar QueryException porque MySQL y MariaDB no soportan esa sintaxis en deletes con join.

    Es un cambio incómodo pero sano — te estaba mintiendo. Si te topas con él, revisa el SQL generado antes de tocar nada:

    11. Nombres de tablas pivote polimórficas

    Al inferir el nombre de tabla de un modelo pivote polimórfico con clase personalizada, Laravel ahora pluraliza. Si dependías del nombre singular inferido, declara la tabla a mano en el pivote:

    class Taggable extends MorphPivot
    {
        protected $table = 'taggable';   // antes se infería así
    }
    

    12. La serialización de colecciones restaura las relaciones eager-loaded

    Cuando una colección de modelos se serializa y se restaura —lo típico en jobs en cola—, las relaciones cargadas con with() ahora vuelven a estar presentes al deserializar.

    Suele ser lo que quieres. Pero si tu job contaba con que las relaciones no estuvieran ahí (por ejemplo, para forzar una recarga fresca desde base de datos), ahora trabaja con datos potencialmente obsoletos. Ese bug es especialmente traicionero porque no falla: simplemente procesa datos viejos.

    13. Los factories de Str se resetean entre tests

    Laravel ahora limpia los factories personalizados de Str en el teardown de cada test. Si definías un generador determinista de UUID o ULID una sola vez —en un setUpBeforeClass o en un helper global— y esperabas que persistiera entre métodos, ahora tienes que definirlo en cada test o en el setUp.

    Síntoma: tests que pasan en solitario y fallan en suite.

    14. Los callbacks de extend se bindean al manager

    Las closures de driver personalizado registradas con extend ahora tienen el manager como $this, no lo que hubiera antes. Si dentro de la closure usabas $this esperando el service provider, muévelo a una captura:

    // Antes: $this era el service provider
    Cache::extend('midriver', function ($app) {
        return $this->crearStore($app);      // ya no funciona
    });
    
    // Ahora
    $provider = $this;
    Cache::extend('midriver', function ($app) use ($provider) {
        return $provider->crearStore($app);
    });
    

    15. Vistas de paginación de Bootstrap 3

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

    Solo importa si referencias esos nombres de vista directamente. Los nombres antiguos eran confusos —default no era el default real— y ahora son explícitos.

    16. Js::from deja de escapar Unicode

    Ahora usa JSON_UNESCAPED_UNICODE por defecto. Si tienes tests o comparaciones de salida que esperaban è en vez de è, actualiza las expectativas. En español esto te toca en cuanto haya un acento.

    17. El polyfill de PHP 8.5 y los helpers globales

    Este es el más fácil de pasar por alto. Laravel 13 añade una dependencia de symfony/polyfill-php85, que en PHP por debajo de 8.5 define funciones globales como array_first() y array_last() si no existen ya.

    El conflicto: el histórico array_first() de laravel/helpers aceptaba un callback para devolver el primer elemento que cumpliera una condición. El del polyfill solo devuelve el primer elemento del array. Misma firma aparente, comportamiento distinto.

    Si arrastras laravel/helpers o helpers globales propios con esos nombres, usa siempre Arr:

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

    Impacto muy bajo: solo si mantienes paquetes

    Si implementas contratos del framework por tu cuenta, hay métodos nuevos que añadir. Si solo consumes Laravel, sáltate esta sección entera.

    ContratoMétodo a añadir
    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

    Además, crear una instancia de un modelo mientras ese modelo todavía se está booteando lanza ahora LogicException:

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

    Y las firmas de throw y throwIf del cliente HTTP ahora declaran sus callbacks explícitamente, cosa que solo importa si sobreescribes esos métodos en una clase de respuesta propia.

    Lo que NO se rompe, aunque lo hayas leído

    Circulan por ahí un par de afirmaciones falsas sobre este upgrade:

    • app/Http/Kernel.php no desaparece en Laravel 13. Ese archivo se eliminó en Laravel 11, con la nueva estructura de aplicación. Si vienes de la 12 ya no lo tienes.
    • Laravel 13 no obliga a PHP 8.4. El mínimo es 8.3 y el rango soportado llega hasta 8.5.
    • VerifyCsrfToken no se elimina. Queda como alias deprecado. Conviene migrarlo, pero no te va a tumbar el deploy.
    • No hay «zero breaking changes». La propia documentación dice minimal, y marca dos cambios de impacto alto. La diferencia entre «mínimo» y «cero» es exactamente este artículo.

    El upgrade, en la práctica

    Con toda la lista delante, el procedimiento es corto:

    # 1. Rama nueva, siempre
    git checkout -b upgrade/laravel-13
    
    # 2. PHP 8.3+ antes que nada
    php -v
    
    # 3. Fija prefijos de caché y sesión para no invalidarlo todo
    #    (CACHE_PREFIX, REDIS_PREFIX, SESSION_COOKIE en tu .env)
    
    # 4. Busca lo que sabes que cambia
    grep -rn "VerifyCsrfToken\|ValidateCsrfToken" app/ tests/ routes/ bootstrap/
    grep -rn "exceptionOccurred\|QueueBusy" app/
    grep -rn "array_first\|array_last" app/
    
    # 5. Actualiza dependencias
    composer update --with-all-dependencies
    
    # 6. Limpia todo
    php artisan optimize:clear
    
    # 7. Tests
    php artisan test
    

    Laravel también ofrece la ruta asistida: si tienes Laravel Boost ^2.0 instalado, el comando /upgrade-laravel-v13 guía el upgrade desde Claude Code, Cursor, OpenCode, Gemini o VS Code. Y para proyectos grandes, Shift sigue siendo la opción de pago que automatiza el grueso.

    Un último paso que casi nadie hace y que ahorra sorpresas: comparar tus archivos de configuración con los de laravel/laravel en la rama 13.x. Muchos cambios de esta lista —serializable_classes sin ir más lejos— viven en config/, no en el framework, y no llegan solos con composer update.

    Checklist antes de mergear

    • PHP 8.3 o superior en local, CI y producción
    • laravel/framework ^13.0, laravel/tinker ^3.0, phpunit ^12.0 o pest ^4.0
    • Cero referencias a VerifyCsrfToken en tests y rutas
    • CACHE_PREFIX, REDIS_PREFIX y SESSION_COOKIE fijados explícitamente
    • config/cache.php revisado si cacheas objetos PHP
    • Todas las llamadas a upsert() pasan un uniqueBy no vacío
    • Listeners de JobAttempted y QueueBusy actualizados
    • Rutas con dominio revisadas si usas subdominios
    • Suite de tests en verde en conjunto, no solo por archivos sueltos
    • Caché vaciada en el deploy

    Conclusión

    Laravel 13 es un upgrade honesto: 10 minutos para la mayoría, y las novedades —el AI SDK first-party, recursos JSON:API, Queue::route(), atributos PHP para middleware y jobs, Cache::touch(), búsqueda vectorial con whereVectorSimilarTo()— llegan sin pedir a cambio una reescritura.

    Pero «minimal» no es «cero», y los cambios que de verdad hacen daño no son los dos de impacto alto —esos vienen documentados y saltan enseguida— sino los de impacto bajo que fallan en silencio: la caché que se invalida entera, el listener que deja de reaccionar, el job que procesa datos viejos, el test que solo falla en suite.

    La fecha manda: 13 de agosto de 2026. Después de eso, cada bug que encuentres en Laravel 12 es tuyo.

    Compartir

    Buscar

    Etiquetas

    Laravel PHP Tutorial Desarrollo Web IA JavaScript Buenas Prácticas Laravel 13 Seguridad Herramientas Claude SEO Expresiones Regulares Manipulación de Texto Frontend