Hace poco tuve que actualizar una aplicación en Laravel 5.5 que llevaba años funcionando en producción con PHP 7. El servidor iba a pasar a PHP 8.4 y la aplicación, simplemente, no iba a arrancar.

En este artículo te cuento qué hice y, sobre todo, los errores que aparecieron por el camino. La mayoría no salen en las guías de actualización oficiales porque son consecuencia de saltar muchas versiones de golpe.

Por qué no basta con cambiar la versión de PHP

Laravel 5.5 y sus dependencias (Symfony 3.4, Carbon 1, PHPUnit 6, SwiftMailer…) no son compatibles con PHP 8. Composer ni siquiera las instala. Y la primera versión de Laravel que soporta PHP 8.4 es la 11, que ya no recibe parches de seguridad.

Así que la única opción razonable es actualizar Laravel a una versión con soporte: en mi caso, Laravel 13, que exige PHP 8.3 o superior.

La estrategia: actualizar sin rehacer la estructura

Laravel 13 sigue aceptando la estructura «clásica» de las aplicaciones antiguas: app/Http/Kernel.php, los service providers, los modelos en app/ o las rutas con 'Controlador@metodo'. No hace falta migrar al esqueleto nuevo para que funcione.

Por eso, para minimizar el impacto, seguí este orden:

  1. Crear una rama nueva y aislar el sitio en PHP 8.4 en local (con Herd: herd isolate 8.4).
  2. Actualizar composer.json y ejecutar composer update.
  3. Corregir el código que rompe.
  4. Probar todas las pantallas, el login, los formularios y la subida de imágenes.

Paso 1: las dependencias

Estos fueron los cambios en composer.json:

Paquete Antes Después
php >=7.0.0 ^8.3
laravel/framework 5.5.* ^13.0
laravel/tinker ~1.0 ^3.0
guzzlehttp/guzzle ^6.3 ^7.10
fideloper/proxy ~3.3 Eliminado (ya viene en el framework)
davejamesmiller/laravel-breadcrumbs ^4.2 diglactic/laravel-breadcrumbs (fork mantenido)
fzaninotto/faker ~1.4 fakerphp/faker
phpunit/phpunit ~6.0 ^13

Los paquetes abandonados hay que sustituirlos por su alternativa mantenida; no tiene sentido intentar forzarlos.

Paso 2: los cambios obligatorios del framework

Estos son los que aparecen en cualquier guía, pero conviene tenerlos a mano:

  • Exception handler: report(Exception $e) y render($request, Exception $e) pasan a recibir Throwable.
  • TrustProxies: se extiende de Illuminate\Http\Middleware\TrustProxies y $headers deja de ser un array para ser una combinación de constantes (Request::HEADER_X_FORWARDED_FOR | ...).
  • Middleware de mantenimiento: CheckForMaintenanceMode se llama ahora PreventRequestsDuringMaintenance.
  • Login: el trait AuthenticatesUsers ya no está en el framework. Puedes instalar laravel/ui o escribir tú los métodos login() y logout() (son unas 30 líneas con Auth::attempt() y RateLimiter).
  • Correo: SwiftMailer se sustituyó por Symfony Mailer, así que config/mail.php cambia de formato (aparece el array mailers) y la variable MAIL_DRIVER pasa a llamarse MAIL_MAILER.
  • Seeders y factories: database/seeds pasa a database/seeders con namespace, y las factories pasan a ser clases.

Los errores que nadie te cuenta

Con todo lo anterior hecho, la aplicación arrancaba… y empezaron los problemas de verdad. Estos son los que me encontré al probarla pantalla a pantalla.

1. {{ $variable or 'valor' }} rompe las vistas

Blade tenía una sintaxis para valores por defecto que se eliminó en Laravel 5.7:

{{-- Antes (Laravel 5.5) --}}
<a class="logo {{ $secondary or false ? 'logo--secondary' : '' }}">

{{-- Después --}}
<a class="logo {{ ($secondary ?? false) ? 'logo--secondary' : '' }}">

En PHP 8, la variable sin definir lanza un error y la página devuelve un 500. Búscalo en todas las vistas con una expresión regular como \{\{[^}]* or [^}]*\}\}.

2. formatLocalized() ya no existe

Carbon 3 eliminó formatLocalized(), porque dependía de strftime(), obsoleta desde PHP 8.1:

// Antes
{{ $post->updated_at->formatLocalized('%d %B %Y') }}

// Después (mismo resultado con el locale por defecto)
{{ $post->updated_at->format('d F Y') }}

// O, si quieres los meses traducidos
{{ $post->updated_at->translatedFormat('j \d\e F \d\e Y') }}

3. Los helpers str_* y array_* desaparecieron

str_slug(), str_limit(), array_get() y compañía se eliminaron en Laravel 6. Se sustituyen por los métodos de Str y Arr:

// Antes
str_slug($post->title)

// Después
\Illuminate\Support\Str::slug($post->title)

Ojo con los ficheros de config/: también los usaban (config/cache.php y config/session.php llamaban a str_slug() para generar el prefijo).

4. $request->get() con arrays devuelve un error 400

Este me costó encontrarlo: al guardar un formulario con varias etiquetas seleccionadas, la aplicación devolvía un 400 Bad Request sin más explicación.

La causa es que desde Symfony 6, InputBag::get() lanza una excepción si el valor es un array:

// Antes: falla si "tags" es un array
foreach ($this->request->get('tags') as $key => $val) { ... }

// Después
foreach ($this->request->all('tags') as $key => $val) { ... }

5. Un método nativo de Eloquent tapa tu scope

La aplicación tenía un scope scopeExcept() en un modelo. En las versiones nuevas, Eloquent incluye un método nativo Builder::except(), que tiene prioridad sobre el scope. El resultado era un error confuso: Call to a member function getKey() on int.

// Antes: llamaba al scope propio
return $this->hasMany(Post::class)->except($id);

// Después: misma consulta, sin ambigüedad
return $this->hasMany(Post::class)->whereKeyNot($id);

Si tienes scopes con nombres genéricos (except, latest, only…), revisa que no choquen con métodos nativos.

6. Sin sesión, el panel devuelve 401 en lugar de redirigir al login

En Laravel 5.5, un usuario sin sesión que entraba en una ruta protegida era redirigido a /login. Con la estructura antigua en Laravel 13, el middleware auth devolvía un 401 vacío.

El esqueleto nuevo lo configura en bootstrap/app.php. Con la estructura clásica, basta una línea en AppServiceProvider:

use Illuminate\Auth\Middleware\Authenticate;

public function boot(): void
{
    Authenticate::redirectUsing(fn () => route('login'));
}

7. El correo deja de enviarse… sin dar error

Laravel 13 usa log como mailer por defecto. Si tu config/mail.php no define default o tu .env de producción todavía usa MAIL_DRIVER, los correos se escriben en el log y nunca salen. Una forma de mantener la compatibilidad con el .env antiguo:

'default' => env('MAIL_MAILER', env('MAIL_DRIVER', 'smtp')),

Lo mismo pasa con CACHE_DRIVER → CACHE_STORE y QUEUE_DRIVER → QUEUE_CONNECTION.

8. Permisos en producción: no despliegues como root

Ya en producción aparecieron dos errores que no tenían nada que ver con el código:

  • fopen(.../storage/framework/cache/locks/...): Failed to open stream. Laravel 13 guarda los bloqueos de la caché en storage/framework/cache/locks, y esa carpeta se había creado como root.
  • Las imágenes subidas devolvían 403. El enlace public/storage se había creado como root y el servidor (Apache con SymLinksIfOwnerMatch) se negaba a seguirlo.

La solución fue devolver la propiedad al usuario de la web:

chown -R usuario:usuario storage bootstrap/cache
chown -h usuario:usuario public/storage

Y, a partir de ahí, ejecutar siempre git, composer y artisan con ese usuario:

sudo -u usuario php artisan migrate --force

Cómo comprobar que todo funciona

Antes de desplegar, merece la pena tener una lista de comprobación:

  • php artisan route:list y php artisan view:cache sin errores.
  • Recorrer todas las pantallas, públicas y privadas.
  • Probar el login y el logout.
  • Enviar los formularios (incluidos los que suben archivos o envían correos).
  • Revisar storage/logs/laravel.log después de navegar.

Y si la aplicación no tiene tests, es el momento perfecto para escribir al menos unos cuantos de las funciones críticas: te avisarán en la próxima actualización.

Conclusión

Saltar de Laravel 5.5 a Laravel 13 es perfectamente posible sin reescribir la aplicación. Los cambios del framework están documentados; lo que lleva tiempo son los detalles que aparecen al acumular varias versiones de golpe: sintaxis de Blade eliminada, helpers que ya no existen, métodos nativos nuevos y valores por defecto que cambian sin avisar.

¿Tienes una aplicación en Laravel antigua que necesita actualizarse? Cuéntame tu caso y vemos juntos cómo hacerlo con el menor impacto posible.