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

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

| Paquete                  | Antes              | Después                                          |
| ------------------------ | ------------------ | ------------------------------------------------ |
| `laravel/framework`      | `^11.0`            | `^12.0`                                          |
| `nesbot/carbon`          | `^2.72.2` (fijado) | sin fijar — lo restringe el framework (Carbon 3) |
| `darkaonline/l5-swagger` | `^8.0`             | `^9.0`                                           |
| `phpunit/phpunit`        | `^10.5`            | `^11.5`                                          |
| `nunomaduro/collision`   | `^8.1`             | `^8.6`                                           |
| `ext-intl`               | no declarada       | `*` (la necesita `Number`)                       |

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

| Antes                                                      | Ahora                                                                     |
| ---------------------------------------------------------- | ------------------------------------------------------------------------- |
| `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`                   | clase del framework (no tenía excepciones)                                |
| `app/Http/Middleware/PreventRequestsDuringMaintenance.php` | clase del framework (sin excepciones)                                     |
| `app/Exceptions/Handler.php`                               | `->withExceptions(fn ($e) => $e->dontFlash([...]))`                       |
| `app/Console/Kernel.php` → `schedule()`                    | [`routes/console.php`](../../routes/console.php) con la facade `Schedule` |
| `app/Providers/RouteServiceProvider.php` → rutas           | `->withRouting(then: ...)`                                                |
| `app/Providers/RouteServiceProvider.php` → rate limiters   | `AppServiceProvider::configureRateLimiting()`                             |
| `app/Providers/BroadcastServiceProvider.php`               | eliminado (broadcasting no se usa)                                        |
| `config/app.php` → `'providers'`                           | [`bootstrap/providers.php`](../../bootstrap/providers.php)                |
| `RouteServiceProvider::HOME`                               | literal `'/home'` en los controladores de `Auth`                          |

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

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

**Regla para el equipo:** al medir contra una fecha pasada, pasa `true` como segundo argumento y
castea a `int` si el destino es entero.

```php theme={null}
// ❌ En Carbon 3 devuelve negativo y la comparación deja de tener sentido
$minutes = now()->diffInMinutes($order->updated_at);
if ($minutes <= 60) { ... }   // -500 <= 60 se cumple SIEMPRE

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

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:

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

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:

```php theme={null}
use PHPUnit\Framework\Attributes\Group;

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

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:

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

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

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

```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      # mismas rutas que antes + /up
php artisan schedule:list   # las 3 tareas con su cadencia original
php artisan l5-swagger:generate
```

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