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 buscasapp/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 enbootstrap/app.php.
Cosas que hay que saber antes de tocar bootstrap/app.php
- El stack global se declara entero con
$middleware->use([...]), no conprepend/append. Cuatro middleware propios (RedirectOldDomain,ForceHttps,SecurityHeaders,BlockBlockedIps) van entreTrustProxiesyHandleCors, y solouse()reproduce ese orden. InvokeDeferredCallbackstiene que ir el primero. Sin él,defer()no ejecuta nada.- Las rutas se registran todas dentro de
then:, no con los argumentosweb:/api:. El orden de registro decide qué ruta gana una URI, yroutes/web.phptambién declara rutas bajoapi/. - El grupo
apiya no traethrottle:apide serie en Laravel 11+. Se añade explícitamente. QrCodeServiceProviderno puede salir debootstrap/providers.php: el paquetesimplesoftwareio/simple-qrcodeno declaraextra.laravel, así que el auto-discovery no lo ve.maatwebsite/excelyssheduardo/redsys-laravelsí lo declaran y por eso no están listados.
Health check
El upgrade añadeGET /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:
true como segundo argumento y
castea a int si el destino es entero.
app/Services/RegistrationCategoryService.php— edad para la categoría del participanteapp/Http/Controllers/Checkout/CheckoutController.php— orden reciente aprobada, y noches de hotelapp/Console/Commands/ExpireAbandonedRateChangeOrdersCommand.php— horas hasta la expiración
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:
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:
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:
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 deNumbercon el de la app: por defecto formatea enen(€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 únicoNumber::useLocale(...)enAppServiceProvider. 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 enFormFieldDisplayValueResolverse 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.jshaceparseFloat(document.getElementById('locatedPrice').textContent). Con formato español,parseFloat("1.234,56 €")devuelve1.234y 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
docsyapi/oauth2-callbackpara todas las documentaciones, así quedefaultysupervisorse pisaban entre sí, y la/docspropia de la app (definida enroutes/web.php) acababa quedándose con la URI y borrando los nombresl5-swagger.*.docs. Como la vista de Swagger los resuelve conroute(),/api/documentationy/api/docs-supervisorrespondían 500. Ahora cada una declara suroutes.docsy suroutes.oauth2_callbackenconfig/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.phpsigue en el repo pero no se registra: broadcasting no se usa (Echo está comentado) y registrarlo añadiría un endpointbroadcasting/authque antes no existía.
