Laravel 13: qué se rompe de verdad al actualizar (y qué no)
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ón | PHP soportado | Publicada | Bug fixes hasta | Seguridad hasta |
|---|---|---|---|---|
| Laravel 13 | 8.3 – 8.5 | 17 mar 2026 | Q3 2027 | 17 mar 2028 |
| Laravel 12 | 8.2 – 8.5 | 24 feb 2025 | 13 ago 2026 | 24 feb 2027 |
| Laravel 11 | 8.2 – 8.4 | 12 mar 2024 | 3 sep 2025 | 12 mar 2026 ❌ |
| Laravel 10 | 8.1 – 8.3 | 14 feb 2023 | 6 ago 2024 | 4 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.
5. Prefijos de caché y nombre de la cookie de sesión
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.
| Contrato | Método a añadir |
|---|---|
Illuminate\Contracts\Cache\Store | touch($key, $seconds) |
Illuminate\Contracts\Bus\Dispatcher | dispatchAfterResponse($command, $handler = null) |
Illuminate\Contracts\Routing\ResponseFactory | eventStream(...) |
Illuminate\Contracts\Auth\MustVerifyEmail | markEmailAsUnverified() |
Illuminate\Contracts\Queue\Queue | pendingSize, 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.phpno 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.
VerifyCsrfTokenno 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.0opest ^4.0 - Cero referencias a
VerifyCsrfTokenen tests y rutas -
CACHE_PREFIX,REDIS_PREFIXySESSION_COOKIEfijados explícitamente -
config/cache.phprevisado si cacheas objetos PHP - Todas las llamadas a
upsert()pasan ununiqueByno vacío - Listeners de
JobAttemptedyQueueBusyactualizados - 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.