Skip to main content

Upgrade a Laravel 12

Esta guía documenta la subida de Laravel 11 a 12 y, sobre todo, dónde vive ahora cada cosa después de migrar al esqueleto moderno. Si buscas app/Http/Kernel.php y no lo encuentras, esta es la página que necesitas.

🎯 Qué cambió

darkaonline/l5-swagger era la única dependencia que bloqueaba el upgrade: la 8.x declara laravel/framework: ^11.0 y no admite la 12.

🗺️ Mapa del esqueleto nuevo

Los tres kernels y el handler desaparecieron. Todo se configura en bootstrap/app.php.

Cosas que hay que saber antes de tocar bootstrap/app.php

  • El stack global se declara entero con $middleware->use([...]), no con prepend/append. Cuatro middleware propios (RedirectOldDomain, ForceHttps, SecurityHeaders, BlockBlockedIps) van entre TrustProxies y HandleCors, y solo use() reproduce ese orden.
  • InvokeDeferredCallbacks tiene que ir el primero. Sin él, defer() no ejecuta nada.
  • Las rutas se registran todas dentro de then:, no con los argumentos web:/api:. El orden de registro decide qué ruta gana una URI, y routes/web.php también declara rutas bajo api/.
  • El grupo api ya no trae throttle:api de serie en Laravel 11+. Se añade explícitamente.
  • QrCodeServiceProvider no puede salir de bootstrap/providers.php: el paquete simplesoftwareio/simple-qrcode no declara extra.laravel, así que el auto-discovery no lo ve. maatwebsite/excel y ssheduardo/redsys-laravel sí lo declaran y por eso no están listados.

Health check

El upgrade añade GET /up, el endpoint de salud estándar de Laravel. Es la única ruta nueva.

⚠️ Carbon 3: diffIn*() cambió de semántica

Es el cambio que más silenciosamente rompe código. En Carbon 2, diffInX() devolvía un entero absoluto. En Carbon 3 devuelve un float con signo:
Regla para el equipo: al medir contra una fecha pasada, pasa true como segundo argumento y castea a int si el destino es entero.
Sitios corregidos en el upgrade, útiles como referencia:
  • app/Services/RegistrationCategoryService.php — edad para la categoría del participante
  • app/Http/Controllers/Checkout/CheckoutController.php — orden reciente aprobada, y noches de hotel
  • app/Console/Commands/ExpireAbandonedRateChangeOrdersCommand.php — horas hasta la expiración
La regresión de la edad está fijada en tests/Feature/RegistrationCategoryAgeTest.php.

🖼️ La regla image ya no acepta SVG

En Laravel 12 la regla image rechaza SVG aunque mimes: lo incluya. Los campos que sí deben aceptarlo lo declaran explícitamente:
Llevan image:allow_svg los banners y logos subidos por organizadores (thumbnail, header y footer de evento, imágenes de idioma y store_cta_banner). El resto de campos —productos, merchandising, foto de perfil, fondo de check-in— se quedan solo con formatos ráster a propósito. Si añades un endpoint nuevo que deba aceptar SVG, tienes que declararlo.

🧪 PHPUnit 11

La metadata en docblock (@group, @dataProvider) sigue funcionando pero está deprecada y desaparece en PHPUnit 12. El proyecto ya usa atributos:
Importante: el CI tiene un gate dedicado php artisan test --group=forms. Si añades un test de formularios, márcalo con #[Group('forms')] para que entre en ese gate.

🧰 Herramientas nuevas ya en uso

defer() — trabajo después de la respuesta

GA4::sendEvent() en el checkout hacía una llamada HTTP síncrona a Google dentro del request. Ahora va diferida:
Los callbacks corren en el terminate() del request y solo si la respuesta fue < 400. Depende de InvokeDeferredCallbacks en el stack global.

Context — correlación de logs de pago

RedsysController puebla el contexto en cuanto conoce los identificadores, y a partir de ahí todas las líneas de log del request los llevan sin repetirlos en cada Log:::

Number — formateo de cifras

Los dashboards de admin y organizador usan Number::currency(), Number::format() y Number::abbreviate(). La salida es idéntica a la del number_format anterior, salvo un arreglo: el patrón manual >= 1000 ? valor/1000 . 'K' mostraba 1,500.0K para 1,5 millones; ahora sale 1.5M.
Nota sobre el idioma. Laravel no sincroniza el locale de Number con el de la app: por defecto formatea en en (€1,234.56), que es justo lo que se mostraba antes. Si algún día se quiere formato español (1.234,56 €), se activa con un único Number::useLocale(...) en AppServiceProvider. Es un cambio visible para el usuario, así que es una decisión de producto, no del upgrade.

Dónde no usar Number

  • app/Exports/: ahí number_format($v, 2, '.', '') produce valores máquina para CSV/Excel, y en FormFieldDisplayValueResolver se usa como clave de comparación. Cambiarlo rompe los exports y el emparejamiento de importes.
  • Importes del checkout: el JavaScript los lee del DOM. public/assets/js/checkout/checkout.js hace parseFloat(document.getElementById('locatedPrice').textContent). Con formato español, parseFloat("1.234,56 €") devuelve 1.234 y el total se convertiría en 1,23 €. Cambiar esos importes exige reescribir antes el JS que los parsea.

✅ Cómo verificar el upgrade en local

📌 Notas

  • Cada documentación de Swagger tiene ahora sus propias rutas. El paquete usa por defecto docs y api/oauth2-callback para todas las documentaciones, así que default y supervisor se pisaban entre sí, y la /docs propia de la app (definida en routes/web.php) acababa quedándose con la URI y borrando los nombres l5-swagger.*.docs. Como la vista de Swagger los resuelve con route(), /api/documentation y /api/docs-supervisor respondían 500. Ahora cada una declara su routes.docs y su routes.oauth2_callback en config/l5-swagger.php. Si añades una tercera documentación, dale las suyas.
  • Las anotaciones OpenAPI @OA\ siguen funcionando. swagger-php 5 mantiene el soporte de docblocks; el JSON generado es byte-idéntico al de la versión anterior. No hace falta migrar a atributos #[OA\...].
  • routes/channels.php sigue en el repo pero no se registra: broadcasting no se usa (Echo está comentado) y registrarlo añadiría un endpoint broadcasting/auth que antes no existía.