Skip to main content

Laravel 12 Upgrade

This guide documents the move from Laravel 11 to 12 and, above all, where everything lives now after migrating to the streamlined skeleton. If you are looking for app/Http/Kernel.php and cannot find it, this is the page you need.

🎯 What changed

darkaonline/l5-swagger was the only dependency blocking the upgrade: 8.x declares laravel/framework: ^11.0 and does not support 12.

🗺️ Map of the new skeleton

The three kernels and the exception handler are gone. Everything is configured in bootstrap/app.php.

Things to know before touching bootstrap/app.php

  • The global stack is declared in full with $middleware->use([...]), not with prepend/append. Four application middleware (RedirectOldDomain, ForceHttps, SecurityHeaders, BlockBlockedIps) sit between TrustProxies and HandleCors, and only use() reproduces that order.
  • InvokeDeferredCallbacks must stay first. Without it, defer() never runs.
  • Every route group is registered inside then:, not through the web:/api: arguments. Registration order decides which route wins a URI, and routes/web.php also declares routes under api/.
  • The api group no longer ships throttle:api in Laravel 11+. It is added explicitly.
  • QrCodeServiceProvider cannot be dropped from bootstrap/providers.php: simplesoftwareio/simple-qrcode declares no extra.laravel section, so package discovery never finds it. maatwebsite/excel and ssheduardo/redsys-laravel do declare it, which is why they are not listed.

Health check

The upgrade adds GET /up, Laravel’s standard health endpoint. It is the only new route.

⚠️ Carbon 3: diffIn*() changed semantics

This is the change that breaks code most silently. In Carbon 2, diffInX() returned an absolute integer. In Carbon 3 it returns a signed float:
Team rule: when measuring against a past date, pass true as the second argument and cast to int when the target is an integer.
Sites fixed during the upgrade, useful as reference:
  • app/Services/RegistrationCategoryService.php — age driving the participant category
  • app/Http/Controllers/Checkout/CheckoutController.php — recent approved order, and hotel nights
  • app/Console/Commands/ExpireAbandonedRateChangeOrdersCommand.php — hours until expiry
The age regression is pinned by tests/Feature/RegistrationCategoryAgeTest.php.

🖼️ The image rule no longer accepts SVG

In Laravel 12 the image rule rejects SVG even when mimes: lists it. Fields that must accept it now say so explicitly:
image:allow_svg is used for organizer-supplied banners and logos (event thumbnail, header and footer, language images and store_cta_banner). Every other field — products, merchandising, profile picture, check-in background — deliberately keeps raster-only formats. If you add a new endpoint that must accept SVG, you have to declare it.

🧪 PHPUnit 11

Docblock metadata (@group, @dataProvider) still works but is deprecated and disappears in PHPUnit 12. The project already uses attributes:
Important: CI runs a dedicated gate, php artisan test --group=forms. When you add a form test, mark it with #[Group('forms')] so it lands in that gate.

🧰 New tools already in use

defer() — work after the response

GA4::sendEvent() used to make a synchronous HTTP call to Google inside the checkout request. It is now deferred:
Callbacks run during the request’s terminate() phase, and only when the response was < 400. This depends on InvokeDeferredCallbacks being in the global stack.

Context — payment log correlation

RedsysController populates the context as soon as it knows the identifiers, and from then on every log line in the request carries them without repeating them in each Log:: call:

Number — figure formatting

The admin and organizer dashboards use Number::currency(), Number::format() and Number::abbreviate(). Output is identical to the previous number_format, with one fix: the hand-rolled >= 1000 ? value/1000 . 'K' pattern rendered 1,500.0K for 1.5 million; it now renders 1.5M.
A note on language. Laravel does not sync Number’s locale with the application locale: it formats in en by default (€1,234.56), which is exactly what was rendered before. If Spanish formatting (1.234,56 €) is ever wanted, a single Number::useLocale(...) in AppServiceProvider switches it. That is a user-visible change, so it is a product decision, not part of the upgrade.

Where not to use Number

  • app/Exports/: there, number_format($v, 2, '.', '') produces machine values for CSV/Excel, and FormFieldDisplayValueResolver uses it as a comparison key. Changing it breaks the exports and amount matching.
  • Checkout amounts: JavaScript reads them out of the DOM. public/assets/js/checkout/checkout.js calls parseFloat(document.getElementById('locatedPrice').textContent). With Spanish formatting, parseFloat("1.234,56 €") returns 1.234 and the total would become €1.23. Changing those amounts requires rewriting the JavaScript that parses them first.

✅ Verifying the upgrade locally

📌 Notes

  • Each Swagger documentation now has its own routes. The package defaults docs and api/oauth2-callback to the same paths for every documentation, so default and supervisor overwrote each other, and the application’s own /docs route (declared in routes/web.php) then took the URI and wiped the l5-swagger.*.docs route names. Since the Swagger view resolves them with route(), /api/documentation and /api/docs-supervisor returned 500. Each documentation now declares its own routes.docs and routes.oauth2_callback in config/l5-swagger.php. If you add a third documentation, give it its own.
  • @OA\ OpenAPI annotations still work. swagger-php 5 keeps docblock support; the generated JSON is byte-identical to the previous version. There is no need to migrate to #[OA\...] attributes.
  • routes/channels.php stays in the repository but is not registered: broadcasting is unused (Echo is commented out) and registering it would add a broadcasting/auth endpoint that did not exist before.