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 forapp/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 inbootstrap/app.php.
Things to know before touching bootstrap/app.php
- The global stack is declared in full with
$middleware->use([...]), not withprepend/append. Four application middleware (RedirectOldDomain,ForceHttps,SecurityHeaders,BlockBlockedIps) sit betweenTrustProxiesandHandleCors, and onlyuse()reproduces that order. InvokeDeferredCallbacksmust stay first. Without it,defer()never runs.- Every route group is registered inside
then:, not through theweb:/api:arguments. Registration order decides which route wins a URI, androutes/web.phpalso declares routes underapi/. - The
apigroup no longer shipsthrottle:apiin Laravel 11+. It is added explicitly. QrCodeServiceProvidercannot be dropped frombootstrap/providers.php:simplesoftwareio/simple-qrcodedeclares noextra.laravelsection, so package discovery never finds it.maatwebsite/excelandssheduardo/redsys-laraveldo declare it, which is why they are not listed.
Health check
The upgrade addsGET /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:
true as the second argument and cast to
int when the target is an integer.
app/Services/RegistrationCategoryService.php— age driving the participant categoryapp/Http/Controllers/Checkout/CheckoutController.php— recent approved order, and hotel nightsapp/Console/Commands/ExpireAbandonedRateChangeOrdersCommand.php— hours until expiry
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:
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:
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 syncNumber’s locale with the application locale: it formats inenby default (€1,234.56), which is exactly what was rendered before. If Spanish formatting (1.234,56 €) is ever wanted, a singleNumber::useLocale(...)inAppServiceProviderswitches 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, andFormFieldDisplayValueResolveruses 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.jscallsparseFloat(document.getElementById('locatedPrice').textContent). With Spanish formatting,parseFloat("1.234,56 €")returns1.234and 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
docsandapi/oauth2-callbackto the same paths for every documentation, sodefaultandsupervisoroverwrote each other, and the application’s own/docsroute (declared inroutes/web.php) then took the URI and wiped thel5-swagger.*.docsroute names. Since the Swagger view resolves them withroute(),/api/documentationand/api/docs-supervisorreturned 500. Each documentation now declares its ownroutes.docsandroutes.oauth2_callbackinconfig/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.phpstays in the repository but is not registered: broadcasting is unused (Echo is commented out) and registering it would add abroadcasting/authendpoint that did not exist before.
