Hace años heredé un proyecto con tres servidores web, un Redis compartido y catorce tareas programadas. Trece llevaban ->onOneServer() al final. La decimocuarta enviaba un resumen semanal de facturas, así que cada lunes a las 06:00 algunos clientes recibían el mismo correo tres veces, con unos segundos de diferencia. Nadie había tomado una decisión rara. Alguien añadió la tarea un viernes y la copió de un proyecto que corría en una sola máquina. Cuando leí las notas de Laravel 13.35, publicada el 6 de octubre, me salté el verbo HTTP y fui directo a Schedule::alwaysOnOneServer(), porque ese bug me había costado un fin de semana.

Mi tesis es esta: los cambios más valiosos de esta versión cogen algo que tenías que repetir en cada tarea o en cada petición y lo convierten en un único ajuste que haces una vez. Llamas a Schedule::alwaysOnOneServer() en un service provider y todos los eventos programados obtienen el bloqueo de servidor único. Aplicas FormRequest::failOnUnknownFields() de forma global y todas las form requests rechazan cualquier dato que sus reglas no mencionen. Desde la 13.35, el modo estricto además ignora los campos _token y _method que añaden @csrf y @method('PUT') de Blade. Antes de ese arreglo, una petición estricta fallaba con un formulario PUT de lo más normal, lo que dejaba el interruptor global casi inservible para aplicaciones renderizadas en servidor. Ahora encaja con la aplicación Laravel más común que existe.

Estos interruptores importan porque recordar una regla en cada llamada no escala con el número de personas que escriben código. Basta con un modificador olvidado en una pull request, y quien revisa no va a detectar algo que simplemente no está. Un ajuste global traslada la decisión desde cada sitio donde escribes una tarea o una petición al único sitio donde arranca tu aplicación. Además te da algo que puedes auditar. schedule:list --json ahora incluye un campo on_one_server para cada evento, así que una comprobación de diez líneas en CI puede romper el build cuando una tarea se desvía. Antes, esa comprobación consistía en hacer grep buscando una llamada a un método y rezar para que nadie la hubiera envuelto en un helper.

La objeción más fuerte merece que la escuchemos en serio, y no es «los cambios incompatibles dan miedo». Los interruptores globales ocultan comportamiento. Con alwaysOnOneServer() activado, la definición de una tarea ya no te dice cómo se ejecuta, y hay tareas que de verdad quieres en todos los nodos: limpiar un directorio temporal local, rotar los logs de cada host, precalentar una caché de archivos cercana a OPcache. La implementación también tiene una arista afilada. Las closures programadas con Schedule::call() quedan fuera del interruptor global si no tienen nombre, lo cual sigue la regla documentada para closures en un solo servidor. Pero quien lea el provider va a dar por hecho que «always» significa siempre. Y el bloqueo depende de un almacén de caché compartido: database, memcached, dynamodb o redis, accesible desde todos los servidores. Si lo activas con el driver de caché file, no tienes ninguna protección.

Acepto todo eso. Mi conclusión es que es un argumento a favor de mejores vías de escape y de fallos más ruidosos, no de seguir cargando el peso sobre quien escribe cada llamada. Una forma de decir por tarea «ejecuta esto en todos los nodos», más un aviso al arrancar cuando el interruptor global está activo y el driver de caché no puede mantener un bloqueo compartido, resolvería casi todo. El caso de la closure sin nombre probablemente debería lanzar una excepción en lugar de volver en silencio a ejecutarse en todos los servidores. Prefiero mil veces un framework seguro por defecto, que me obligue a renunciar a la seguridad a propósito, antes que uno donde el camino seguro depende de mi memoria a las 17:40 de un viernes.

El resto de la versión apoya el mismo argumento desde otro ángulo. chunkById() y lazyById() entraban en un bucle infinito con modelos cuya clave primaria tenía un cast. withCasts() sobre una consulta clonada modificaba la original. Los ciclos de referencias en el callback por defecto de PendingRequest::throw() y en los callbacks headers() y priority() de Mailable retenían memoria sin avisar en workers de larga duración. Ninguno de estos arreglos salió en titulares, y cada uno era una trampa que solo podías esquivar si ya la conocías. Las nuevas piezas opcionales siguen el mismo patrón prudente. lazy_root_creation para discos locales está en false por defecto, y el trait HasDefaultAttributes no hace nada hasta que lo añades. Es lo correcto para una versión menor. Las versiones menores sirven para añadir interruptores, y las mayores son donde se decide hacia qué lado apuntan.

Así que mi deseo para Laravel 14 es concreto. Que el scheduling en un solo servidor sea el comportamiento por defecto siempre que el almacén de caché configurado admita bloqueos, con una forma explícita de desactivarlo por tarea. Que los proyectos nuevos traigan form requests estrictas de serie, ahora que el problema de Blade ya no existe. Y que lo demás se quede como está. Route::query() está bien tenerlo, pero el soporte de QUERY en clientes, proxies y CDN todavía es desigual, así que no debería ser el motivo de nadie para actualizar. Aun así, ejecuta composer update laravel/framework esta semana y pasa tus tests, sobre todo si usas morphTo, HasOneThrough o casts JSON.

Ahora quiero saber tu opinión, sobre todo si tienes Laravel corriendo en más de una máquina. Si alwaysOnOneServer() pasara a ser el comportamiento por defecto mañana, ¿cuáles de tus tareas programadas se romperían porque necesitan ejecutarse en todos los servidores? ¿Y preferirías marcar esas tareas o el otro noventa por ciento?