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:
- Crear una rama nueva y aislar el sitio en PHP 8.4 en local (con Herd:
herd isolate 8.4). - Actualizar
composer.jsony ejecutarcomposer update. - Corregir el código que rompe.
- 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)yrender($request, Exception $e)pasan a recibirThrowable. - TrustProxies: se extiende de
Illuminate\Http\Middleware\TrustProxiesy$headersdeja de ser un array para ser una combinación de constantes (Request::HEADER_X_FORWARDED_FOR | ...). - Middleware de mantenimiento:
CheckForMaintenanceModese llama ahoraPreventRequestsDuringMaintenance. - Login: el trait
AuthenticatesUsersya no está en el framework. Puedes instalarlaravel/uio escribir tú los métodoslogin()ylogout()(son unas 30 líneas conAuth::attempt()yRateLimiter). - Correo: SwiftMailer se sustituyó por Symfony Mailer, así que
config/mail.phpcambia de formato (aparece el arraymailers) y la variableMAIL_DRIVERpasa a llamarseMAIL_MAILER. - Seeders y factories:
database/seedspasa adatabase/seederscon 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é enstorage/framework/cache/locks, y esa carpeta se había creado comoroot.- Las imágenes subidas devolvían 403. El enlace
public/storagese había creado comorooty el servidor (Apache conSymLinksIfOwnerMatch) 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:listyphp artisan view:cachesin 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.logdespué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.