Il y a quelques années, j'ai hérité d'une base de code avec trois serveurs web, un Redis partagé et quatorze tâches planifiées. Treize d'entre elles se terminaient par ->onOneServer(). La quatorzième envoyait un récapitulatif hebdomadaire des factures, si bien que chaque lundi à 06:00, certains clients recevaient le même e-mail trois fois, à quelques secondes d'intervalle. Personne n'avait pris de décision bizarre. Quelqu'un avait ajouté une tâche un vendredi en la copiant depuis un projet qui tournait sur une seule machine. Quand j'ai lu les notes de version de Laravel 13.35, taguée le 6 octobre, j'ai sauté le nouveau verbe HTTP pour filer directement à Schedule::alwaysOnOneServer(), parce que ce bug m'avait coûté un week-end.

Voilà ma thèse : les changements les plus utiles de cette version prennent quelque chose que tu devais répéter sur chaque tâche ou chaque requête et le transforment en un réglage unique, posé une fois pour toutes. Tu appelles Schedule::alwaysOnOneServer() dans un service provider, et chaque événement planifié reçoit le verrou mono-serveur. Tu appliques FormRequest::failOnUnknownFields() en global, et chaque form request rejette les champs que ses règles ne mentionnent pas. Depuis la 13.35, le mode strict ignore aussi les champs _token et _method qu'ajoutent @csrf et @method('PUT') dans Blade. Avant ce correctif, une requête stricte échouait sur un simple formulaire PUT, ce qui rendait le réglage global quasi inutilisable pour les applis rendues côté serveur. Désormais, il convient à l'appli Laravel la plus banale qui soit.

Ces réglages comptent parce que se souvenir d'une règle à chaque appel ne tient pas quand le nombre de personnes qui écrivent du code augmente. Un seul modificateur oublié dans une pull request suffit, et aucun relecteur ne repère ce qui manque. Un réglage global déplace la décision de chaque endroit où tu écris une tâche ou une requête vers l'unique endroit où ton appli démarre. Et il te donne quelque chose d'auditable. schedule:list --json expose maintenant un champ on_one_server pour chaque événement, donc dix lignes de vérification en CI suffisent pour casser le build quand une tâche dérive. Avant, ce contrôle consistait à faire un grep sur un appel de méthode en priant pour que personne ne l'ait enveloppé dans un helper.

L'objection la plus sérieuse mérite d'être entendue, et ce n'est pas « les breaking changes, ça fait peur ». Les réglages globaux cachent du comportement. Une fois alwaysOnOneServer() activé, la définition d'une tâche ne te dit plus comment elle s'exécute, et il existe des tâches que tu veux vraiment sur chaque nœud : vider un dossier temporaire local, faire tourner les logs propres à chaque machine, préchauffer un cache de fichiers voisin d'OPcache. L'implémentation a aussi un angle vif. Les closures planifiées avec Schedule::call() sont ignorées par le réglage global si elles n'ont pas de nom, ce qui respecte la règle documentée pour les closures mono-serveur. Mais un développeur qui lit le provider supposera que « always » veut dire toujours. Enfin, le verrou dépend d'un store de cache partagé, database, memcached, dynamodb ou redis, accessible depuis tous les serveurs. Active ça avec le driver de cache file et tu n'as strictement aucune protection.

J'accepte tout ça. Ma conclusion, c'est que ces arguments plaident pour de meilleures portes de sortie et des échecs plus bruyants, pas pour laisser le fardeau à l'appelant. Une manière, tâche par tâche, de dire « exécute ça sur chaque nœud », plus un avertissement au démarrage quand le réglage global est actif et que le driver de cache ne peut pas tenir un verrou partagé, réglerait l'essentiel. Le cas des closures sans nom devrait sans doute lever une exception au lieu de revenir en silence à une exécution sur tous les serveurs. Je préfère de loin un framework sûr par défaut, qui m'oblige à renoncer à la sécurité volontairement, à un framework où le chemin sûr dépend de ma mémoire un vendredi à 17:40.

Le reste de la version appuie le même argument sous un autre angle. chunkById() et lazyById() bouclaient à l'infini sur les modèles dont la clé primaire avait un cast. withCasts() sur une requête clonée modifiait l'originale. Des cycles de références dans le callback par défaut de PendingRequest::throw() et dans les callbacks headers() et priority() des Mailable retenaient discrètement de la mémoire dans les workers longue durée. Aucun de ces correctifs n'a fait les gros titres, et chacun était un piège qu'on ne pouvait éviter qu'en le connaissant déjà. Les nouveautés optionnelles suivent la même prudence. lazy_root_creation pour les disques locaux vaut false par défaut, et le trait HasDefaultAttributes ne fait rien tant que tu ne l'ajoutes pas. C'est le bon choix pour une version mineure. Les versions mineures servent à ajouter des interrupteurs, les versions majeures à changer leur position.

Mon souhait pour Laravel 14 est donc précis. Faire du scheduling mono-serveur le comportement par défaut dès que le store de cache configuré gère les verrous, avec une désactivation explicite par tâche. Donner aux nouveaux projets des form requests strictes d'emblée, maintenant que le problème Blade est réglé. Et laisser le reste tranquille. Route::query() est sympa, mais le support de QUERY côté clients, proxys et CDN reste inégal, donc ça ne devrait motiver la mise à jour de personne. Lance quand même composer update laravel/framework cette semaine, et fais tourner tes tests, surtout si tu utilises morphTo, HasOneThrough ou des casts JSON.

Maintenant j'aimerais ton avis, surtout si tu fais tourner Laravel sur plus d'une machine. Si alwaysOnOneServer() devenait le comportement par défaut demain, lesquelles de tes tâches planifiées casseraient parce qu'elles doivent s'exécuter sur chaque serveur, et tu préférerais marquer ces tâches-là ou les quatre-vingt-dix pour cent restantes ?