> ## Documentation Index
> Fetch the complete documentation index at: https://docs.rocky.global/llms.txt
> Use this file to discover all available pages before exploring further.

# LARAVEL 12 UPGRADE

# 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

| Package                  | Before             | After                                              |
| ------------------------ | ------------------ | -------------------------------------------------- |
| `laravel/framework`      | `^11.0`            | `^12.0`                                            |
| `nesbot/carbon`          | `^2.72.2` (pinned) | unpinned — constrained by the framework (Carbon 3) |
| `darkaonline/l5-swagger` | `^8.0`             | `^9.0`                                             |
| `phpunit/phpunit`        | `^10.5`            | `^11.5`                                            |
| `nunomaduro/collision`   | `^8.1`             | `^8.6`                                             |
| `ext-intl`               | not declared       | `*` (required by `Number`)                         |

`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`](../../../bootstrap/app.php).

| Before                                                     | Now                                                                             |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `app/Http/Kernel.php` → `$middleware`                      | `bootstrap/app.php` → `$middleware->use([...])`                                 |
| `app/Http/Kernel.php` → `$middlewareGroups['web']`         | `$middleware->web(append: [...])`                                               |
| `app/Http/Kernel.php` → `$middlewareGroups['api']`         | `$middleware->api(prepend: [...], append: [...])`                               |
| `app/Http/Kernel.php` → `$middlewareAliases`               | `$middleware->alias([...])`                                                     |
| `app/Http/Middleware/VerifyCsrfToken.php`                  | `$middleware->validateCsrfTokens(except: [...])`                                |
| `app/Http/Middleware/TrustProxies.php`                     | `$middleware->trustProxies(at: '*', headers: ...)`                              |
| `app/Http/Middleware/TrimStrings.php`                      | `$middleware->trimStrings(except: [...])`                                       |
| `app/Http/Middleware/Authenticate.php`                     | `$middleware->redirectGuestsTo(...)`                                            |
| `app/Http/Middleware/EncryptCookies.php`                   | framework class (it had no exceptions)                                          |
| `app/Http/Middleware/PreventRequestsDuringMaintenance.php` | framework class (no exceptions)                                                 |
| `app/Exceptions/Handler.php`                               | `->withExceptions(fn ($e) => $e->dontFlash([...]))`                             |
| `app/Console/Kernel.php` → `schedule()`                    | [`routes/console.php`](../../../routes/console.php) using the `Schedule` facade |
| `app/Providers/RouteServiceProvider.php` → routes          | `->withRouting(then: ...)`                                                      |
| `app/Providers/RouteServiceProvider.php` → rate limiters   | `AppServiceProvider::configureRateLimiting()`                                   |
| `app/Providers/BroadcastServiceProvider.php`               | removed (broadcasting is unused)                                                |
| `config/app.php` → `'providers'`                           | [`bootstrap/providers.php`](../../../bootstrap/providers.php)                   |
| `RouteServiceProvider::HOME`                               | the literal `'/home'` in the `Auth` controllers                                 |

### 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**:

```php theme={null}
$ref   = Carbon::parse('2026-03-01');
$birth = Carbon::parse('1990-06-15');

$ref->diffInYears($birth);        // Carbon 2: 35   | Carbon 3: -35.70958904109589
$ref->diffInYears($birth, true);  // Carbon 3: 35.70958904109589
```

**Team rule:** when measuring against a past date, pass `true` as the second argument and cast to
`int` when the target is an integer.

```php theme={null}
// ❌ Negative under Carbon 3, so the comparison stops meaning anything
$minutes = now()->diffInMinutes($order->updated_at);
if ($minutes <= 60) { ... }   // -500 <= 60 is ALWAYS true

// ✅
$minutes = (int) now()->diffInMinutes($order->updated_at, true);
```

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:

```php theme={null}
'inputThumbnailImage' => 'image:allow_svg|mimes:jpeg,png,jpg,gif,svg|nullable',
```

`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 theme={null}
use PHPUnit\Framework\Attributes\Group;

#[Group('forms')]
class RegistrationFormFieldStateTest extends TestCase
```

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:

```php theme={null}
defer(fn () => GA4::sendEvent([...]));
```

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:

```php theme={null}
Context::add(['gateway' => 'redsys', 'order_id' => $order->id, 'internal_code' => $order->internal_code]);
Log::info('Redsys notify signature', ['isValid' => $isValid]);
// [...] local.INFO: Redsys notify signature {"isValid":true} {"gateway":"redsys","order_id":4321,...}
```

### `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

```bash theme={null}
composer validate --strict
./vendor/bin/pint --test
php artisan test --group=forms
php artisan test

php artisan about           # Laravel 12.x
php artisan route:list      # same routes as before, plus /up
php artisan schedule:list   # the 3 tasks with their original cadence
php artisan l5-swagger:generate
```

## 📌 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.
