> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# Enregistrement et maintenance des tâches planifiées d'un package

> Analysez Schedule et les hooks post-résolution de Laravel 13 pour concevoir des tâches planifiées de package activées explicitement, et comprendre le double enregistrement, les noms de verrou, les changements de configuration et les tests.

Pour ajouter une synchronisation périodique ou un heartbeat à un package, séparez **la fourniture du traitement** de **son enregistrement dans le planificateur**. La simple installation du package ne doit pas ajouter de tâches périodiques à l'application cliente : concevez-le pour que l'utilisateur choisisse l'activation, la fréquence d'exécution et le comportement sur plusieurs serveurs.

Cette page examine le code source officiel de Laravel Framework `v13.39.0` et applique les opérations de base de la [Planification de tâches](https://kawax.biz/fr/scheduling.md) au développement de packages. Pour le chargement des providers, consultez [Fonctionnement interne de la découverte automatique des packages](https://kawax.biz/fr/advanced/package-discovery.md) ; pour la publication de la configuration, consultez [Fusion et cache de la configuration des packages](https://kawax.biz/fr/advanced/package-config-merging.md).

## Enregistrer après la résolution de Schedule

Dans une application Laravel classique, `FoundationServiceProvider` enregistre `Schedule::class` en tant que singleton. Sa création appelle `resolveConsoleSchedule()` du Console Kernel, qui applique le fuseau horaire de l'application et le store de cache dédié au planificateur.

Le package doit étendre ce Schedule partagé, et non se contenter de créer son propre `new Schedule()`. Le provider suivant suppose que `Acme\Courier\` correspond à `src/` via le PSR-4 de Composer et que le package contient `config/courier.php`.

```php theme={null}
<?php

namespace Acme\Courier;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\ServiceProvider;

class CourierServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->mergeConfigFrom(__DIR__.'/../config/courier.php', 'courier');

        $this->app->singleton(CourierSchedule::class);
    }

    public function boot(): void
    {
        if (! $this->app->runningInConsole()) {
            return;
        }

        $this->callAfterResolving(Schedule::class, function (Schedule $schedule): void {
            $this->app->make(CourierSchedule::class)->register($schedule);
        });
    }
}
```

`callAfterResolving()` attend la prochaine résolution si le service n'est pas encore résolu ; s'il l'est déjà, il le récupère et appelle aussi le callback immédiatement. Ainsi, l'enregistrement fonctionne même si un autre provider a utilisé Schedule auparavant. Pour plus de détails, consultez [Hooks de résolution des services d'un package](https://kawax.biz/fr/advanced/package-service-resolution.md).

`runningInConsole()` ne détecte pas une commande Artisan précise. Dans cet exemple, l'utilisation de Schedule via HTTP est exclue. Par ailleurs, le provider qui enregistre le hook ne doit pas lui-même être différé : si `boot()` ne s'exécute pas avant le moment requis, le hook ne sera pas enregistré non plus.

```mermaid theme={null}
flowchart TD
    A["boot() du provider du package"] --> B["Enregistrer le hook post-résolution de Schedule"]
    B --> C{"Schedule déjà résolu ?"}
    C -->|Non| D["Attendre la première résolution"]
    C -->|Oui| E["Récupérer le singleton existant"]
    D --> F["Vérifier l'activation et l'enregistrement existant"]
    E --> F
    F -->|Activé et non enregistré| G["Ajouter la définition de l'événement"]
    F -->|Désactivé ou déjà enregistré| H["Ne rien ajouter"]
    G --> I["Évaluation des conditions d'exécution<br>lors d'un schedule:run ultérieur"]
```

> [!NOTE]
>
> Enregistrer un événement et exécuter une tâche sont deux choses distinctes. Charger ce provider n'exécute pas le heartbeat sur-le-champ. L'enregistrement seul ne démarre ni cron ni `schedule:work`.

## Activation explicite par l'utilisateur

Le fichier `config/courier.php` du package est désactivé par défaut.

```php theme={null}
<?php

return [
    'schedule' => [
        'enabled' => false,
        'cron' => '*/5 * * * *',
        'timezone' => 'UTC',
        'overlap_minutes' => 10,
        'on_one_server' => false,
    ],
];
```

Dans l'application cliente, publiez ou créez la configuration et passez `schedule.enabled` à `true`. Dans cet exemple, les valeurs sont lues au moment de l'enregistrement. Le fuseau horaire par défaut est UTC ; si vous en choisissez un autre, tenez compte des exécutions dupliquées ou manquées dues à l'heure d'été.

La configuration étant [fusionnée de manière superficielle](https://kawax.biz/fr/advanced/package-config-merging.md), le registraire spécifie aussi une valeur par défaut pour chaque clé. Même si une surcharge partielle fait disparaître les autres clés du tableau `schedule`, l'enregistrement reste possible avec les réglages de base.

## Empêcher le double enregistrement dans le même Schedule

Le fichier `src/CourierSchedule.php` suivant enregistre un heartbeat minimal qui écrit une entrée de log. Grâce à `WeakMap`, **ce registraire n'ajoute la tâche qu'une seule fois à un même objet Schedule**.

```php theme={null}
<?php

namespace Acme\Courier;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Contracts\Config\Repository;
use Psr\Log\LoggerInterface;
use WeakMap;

final class CourierSchedule
{
    public const EVENT_NAME = 'acme-courier:heartbeat';

    private WeakMap $registered;

    public function __construct(private Repository $config)
    {
        $this->registered = new WeakMap();
    }

    public function register(Schedule $schedule): void
    {
        if (! $this->config->get('courier.schedule.enabled', false)
            || isset($this->registered[$schedule])) {
            return;
        }

        $event = $schedule->call(static function (LoggerInterface $logger): void {
            $logger->info('Courier heartbeat');
        })
            ->name(self::EVENT_NAME)
            ->cron($this->config->get('courier.schedule.cron', '*/5 * * * *'))
            ->timezone($this->config->get('courier.schedule.timezone', 'UTC'))
            ->withoutOverlapping($this->config->get('courier.schedule.overlap_minutes', 10));

        if ($this->config->get('courier.schedule.on_one_server', false)) {
            $event->onOneServer();
        }

        $this->registered[$schedule] = true;
    }
}
```

Comme ce registraire est un singleton, la vérification d'enregistrement est partagée même s'il est récupéré à nouveau depuis le hook du provider. En revanche, il peut s'enregistrer dans un nouveau Schedule. Évitez un indicateur statique « déjà enregistré » à l'échelle du processus, qui exclurait aussi les nouveaux conteneurs et les Schedule des tests.

`Schedule::call()`, `exec()` et `job()` ajoutent l'événement à un tableau. Ce ne sont pas des API qui recherchent un événement de même nom pour le remplacer ou dédupliquer.

> [!WARNING]
>
> `withoutOverlapping()` ne corrige pas un double enregistrement. Si la même tâche est enregistrée deux fois, l'événement suivant peut s'exécuter une fois l'exécution précédente terminée et le verrou libéré. `onOneServer()` n'élimine pas non plus les doubles enregistrements.
>
> Le WeakMap ci-dessus n'empêche que les doublons de ce registraire. Si l'utilisateur ajoute la même tâche dans `routes/console.php`, désactivez l'enregistrement automatique du package et confiez l'enregistrement à un seul endroit.

## Le nom de l'événement affecte aussi la compatibilité des verrous

`name()` est un alias de `description()`, mais son effet sur le nom du verrou dépend du type d'événement. Voici les noms de mutex par défaut.

| Événement | Valeur à l'origine du nom de mutex | Rôle de `name()` |
| - | - | - |
| Event de `command()` et `exec()` | Expression cron et chaîne de commande normalisée | Description pour l'affichage. Non incluse dans le nom de mutex par défaut |
| CallbackEvent de `call()` et `job()` | Description de l'événement | Nom d'affichage et identification du verrou. L'expression cron n'est pas incluse |

Pour un CallbackEvent, attribuez **un nom non vide avant** `withoutOverlapping()` ou `onOneServer()`. Si aucun nom n'est défini, ces méthodes lèvent une exception.

`job()` utilise généralement le nom de la classe du job ou similaire comme nom initial. Cependant, si vous enregistrez le même job avec des arguments ou des cibles différents, indiquez explicitement un nom par cible. Changer seulement les arguments ne produit pas automatiquement un nom initial différent.

```php theme={null}
use Acme\Courier\Jobs\SyncCatalogJob;
use Illuminate\Support\Facades\Schedule;

Schedule::job(new SyncCatalogJob('tenant-a'))
    ->name('acme-courier:catalog:tenant-a')
    ->everyFiveMinutes()
    ->onOneServer();

Schedule::job(new SyncCatalogJob('tenant-b'))
    ->name('acme-courier:catalog:tenant-b')
    ->everyFiveMinutes()
    ->onOneServer();
```

`SyncCatalogJob` est un exemple de classe de job fournie par le package. Pour l'implémentation des jobs, consultez [Queues et jobs](https://kawax.biz/fr/queues.md).

Selon le type, modifier le nom, la commande ou l'expression cron change aussi l'identifiant du verrou. Lors d'un déploiement où coexistent des processus de l'ancienne et de la nouvelle version, un même traitement peut acquérir des verrous différents. Ne traitez pas cela comme un simple changement de libellé : gérez le nom de la tâche comme une spécification publique stable.

### Distinguer onOneServer et withoutOverlapping

* `onOneServer()` choisit le serveur chargé de l'exécution pour cet horaire. Le `CacheSchedulingMutex` examiné ajoute l'heure et la minute au nom de mutex de l'événement et le conserve avec une expiration d'une heure. C'est distinct du verrou anti-chevauchement libéré à la fin de la tâche.
* `withoutOverlapping()` empêche les chevauchements tant que l'exécution précédente se poursuit. L'expiration indiquée est en minutes, avec 1 440 minutes par défaut. Une expiration trop courte fait expirer le verrou avant la fin du traitement.
* Lorsque `job()` envoie le job dans une queue, la fin de l'événement planifié ne signifie pas la fin du traitement du job. Concevez séparément le contrôle des doublons côté worker, par exemple avec les jobs uniques ou le middleware `WithoutOverlapping`.

Pour les utilisateurs qui activent `onOneServer()`, indiquez comme prérequis une configuration de cache partagé `database`, `memcached`, `dynamodb` ou `redis`, comme le précise la documentation officielle. Les verrous ne garantissent pas une exécution exactly-once du traitement métier : celui-ci doit aussi supporter une ré-exécution sans dommage.

## Résoudre les dépendances lourdes à l'exécution

Le `LoggerInterface` du heartbeat ci-dessus est résolu par `CallbackEvent` via `call()` du conteneur au moment de l'exécution de l'événement. Cette configuration n'écrit pas de log et ne crée pas de client lors de l'enregistrement du hook.

| Méthode de définition | Point d'attention à l'enregistrement |
| - | - |
| Passer une closure à `call()` | Le corps est appelé plus tard. Toutefois, les objets créés à l'extérieur et capturés sont déjà instanciés |
| `call(new Task)` | `new Task` est exécuté lors de l'enregistrement |
| `command(CommandClass::class)` | Schedule crée la classe via le conteneur pour obtenir son nom et sa description |
| `job(JobClass::class)` | La création du job attend l'exécution, dans le callback de l'événement |

Éviter les connexions externes et la logique métier dans le constructeur est également important pour la [résolution des dépendances des commandes Artisan](https://kawax.biz/fr/advanced/package-commands.md).

Par ailleurs, `call()` et `job()` étant des CallbackEvent, `runInBackground()` n'est pas disponible. Déléguez les traitements longs à une queue ou, si nécessaire, choisissez l'exécution en arrière-plan de `command()`.

## Ne pas s'approprier la configuration globale de l'application

`Schedule::useCache()` modifie le store de l'objet mutex détenu par Schedule. Ce réglage ne concerne pas uniquement les événements du package : il affecte aussi les événements qui partagent le même mutex. Ne l'appelez pas inconditionnellement dans le `boot()` du package ; traitez-le comme un réglage choisi par l'application cliente.

De même, le package ne doit pas modifier de lui-même `Schedule::alwaysOnOneServer()`, la mise en pause du planificateur ou la politique d'exécution en mode maintenance.

## Recréer Schedule après un changement de configuration

Dans l'exemple, `enabled`, l'expression cron et le fuseau horaire sont des réglages lus à l'enregistrement. Modifier simplement les valeurs de `config()` pour un Schedule déjà créé n'ajoute ni ne supprime d'événements et ne relit pas la configuration. La vérification par WeakMap n'est pas non plus destinée à reconfigurer.

Pour un déploiement utilisant le cache de configuration, reconstruisez le cache et vérifiez `schedule:list` dans un processus qui charge la nouvelle configuration. Pour les planificateurs de longue durée ou un `schedule:run` en train d'exécuter des tâches de moins d'une minute, vérifiez aussi que l'ancien code et l'ancienne configuration ne subsistent pas. Pour l'interruption des tâches sub-minute lors d'un déploiement, consultez [Planification de tâches](https://kawax.biz/fr/scheduling.md).

## Tester l'enregistrement séparément de l'exécution

Voici un exemple de `tests/CourierScheduleTest.php` utilisant l'autoload Composer du package et PHPUnit. Les mutex sont remplacés par des doublures de test afin de vérifier le résultat de l'enregistrement sans cache externe ni véritable cron.

```php theme={null}
<?php

namespace Acme\Courier\Tests;

use Acme\Courier\CourierSchedule;
use Illuminate\Config\Repository;
use Illuminate\Console\Scheduling\EventMutex;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Console\Scheduling\SchedulingMutex;
use Illuminate\Container\Container;
use PHPUnit\Framework\TestCase;

class CourierScheduleTest extends TestCase
{
    private Container $previousContainer;
    private Repository $config;
    private CourierSchedule $registrar;

    protected function setUp(): void
    {
        parent::setUp();

        $this->previousContainer = Container::getInstance();
        $container = new Container();
        Container::setInstance($container);

        $container->instance(EventMutex::class, $this->createMock(EventMutex::class));
        $container->instance(SchedulingMutex::class, $this->createMock(SchedulingMutex::class));

        $this->config = new Repository();
        $this->registrar = new CourierSchedule($this->config);
    }

    protected function tearDown(): void
    {
        Container::setInstance($this->previousContainer);

        parent::tearDown();
    }

    public function test_disabled_by_default(): void
    {
        $schedule = new Schedule();
        $this->registrar->register($schedule);

        $this->assertCount(0, $schedule->events());
    }

    public function test_partial_config_uses_defaults_without_duplicate_registration(): void
    {
        $this->config->set('courier.schedule.enabled', true);
        $schedule = new Schedule();

        $this->registrar->register($schedule);
        $this->registrar->register($schedule);

        $this->assertCount(1, $schedule->events());
        $event = $schedule->events()[0];
        $this->assertSame(CourierSchedule::EVENT_NAME, $event->description);
        $this->assertSame('*/5 * * * *', $event->expression);
        $this->assertSame('UTC', $event->timezone);
        $this->assertTrue($event->withoutOverlapping);
        $this->assertSame(10, $event->expiresAt);
        $this->assertFalse($event->onOneServer);
    }

    public function test_explicit_schedule_settings_are_applied(): void
    {
        $this->config->set('courier.schedule', [
            'enabled' => true,
            'cron' => '0 * * * *',
            'timezone' => 'Asia/Tokyo',
            'overlap_minutes' => 30,
            'on_one_server' => true,
        ]);

        $schedule = new Schedule();
        $this->registrar->register($schedule);
        $event = $schedule->events()[0];

        $this->assertSame('0 * * * *', $event->expression);
        $this->assertSame('Asia/Tokyo', $event->timezone);
        $this->assertSame(30, $event->expiresAt);
        $this->assertTrue($event->onOneServer);
    }

    public function test_setting_changes_require_a_new_schedule(): void
    {
        $this->config->set('courier.schedule.enabled', true);
        $first = new Schedule();
        $this->registrar->register($first);

        $this->config->set('courier.schedule.cron', '0 * * * *');
        $this->registrar->register($first);

        $second = new Schedule();
        $this->registrar->register($second);

        $this->assertCount(1, $first->events());
        $this->assertCount(1, $second->events());
        $this->assertSame('*/5 * * * *', $first->events()[0]->expression);
        $this->assertSame('0 * * * *', $second->events()[0]->expression);
    }
}
```

Ce test est un test unitaire du registraire. Vérifiez séparément la découverte automatique du provider, la fusion de la configuration et l'intégration au Console Kernel avec [Orchestra Testbench](https://kawax.biz/fr/advanced/package-testing.md) ou une application de vérification.

| Cas à vérifier | Critère de réussite |
| - | - |
| Installation avec la configuration par défaut | Les tâches planifiées du package ne sont pas enregistrées |
| `schedule:list` après activation | Un seul événement existe, avec une fréquence et un fuseau horaire corrects |
| Démarrage du provider avant et après la résolution de Schedule | L'enregistrement est pris en compte dans les deux cas |
| Enregistrement uniquement | Aucun service métier n'est créé et aucun traitement ne démarre |
| Exécution isolée du corps de la tâche | Les dépendances nécessaires sont résolues et le traitement ou les exceptions attendus sont vérifiables |
| Plusieurs serveurs et traitements longs | Vérifier séparément le prérequis du cache partagé et les deux types de verrous |
| Mise à jour avec cache de configuration | Ni anciennes tâches ni doubles enregistrements ne subsistent dans le nouveau processus |

Ne lancez pas `schedule:run` pour exécuter toutes les tâches de l'application cliente dans le seul but de faire passer les tests d'enregistrement. En séparant les tests du traitement lui-même et les tests d'intégration du planificateur dans un environnement contrôlé, vous évitez les effets de bord de tâches périodiques sans rapport.

## Pages associées

> [card] [Planification de tâches](https://kawax.biz/fr/scheduling.md)
>
> Fréquence d'exécution, mise en place de cron, délégation aux queues et exploitation lors des déploiements.

> [card] [Hooks de résolution des services d'un package](https://kawax.biz/fr/advanced/package-service-resolution.md)
>
> Application aux services non résolus et déjà résolus, et points d'attention lors d'un nouvel enregistrement.

## Sources primaires consultées

* [Documentation officielle de Laravel : Planification de tâches](https://github.com/laravel/docs/blob/960eedf0911155ad16326ade49c0ea5d18eda0b8/scheduling.md)
* [Documentation officielle de Laravel : Développement de packages](https://github.com/laravel/docs/blob/960eedf0911155ad16326ade49c0ea5d18eda0b8/packages.md)
* [FoundationServiceProvider : enregistrement de Schedule en singleton](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php)
* [Console Kernel : création de Schedule et réglages par défaut](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Foundation/Console/Kernel.php)
* [ServiceProvider : callAfterResolving()](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/ServiceProvider.php)
* [Schedule : ajout d'événements, création différée des jobs, partage du mutex](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/Schedule.php)
* [Event : nom de mutex des événements de commande](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/Event.php)
* [CallbackEvent : nom obligatoire et résolution des dépendances à l'exécution](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/CallbackEvent.php)
* [CacheEventMutex : verrou anti-chevauchement et conversion de l'expiration](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/CacheEventMutex.php)
* [CacheSchedulingMutex : verrou par horaire d'exécution](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/CacheSchedulingMutex.php)
* [Tests officiels : enregistrement des événements et fuseau horaire](https://github.com/laravel/framework/blob/v13.39.0/tests/Console/ConsoleEventSchedulerTest.php)
* [Tests officiels : création différée par nom de classe de job](https://github.com/laravel/framework/blob/v13.39.0/tests/Console/Scheduling/ScheduleTest.php)


## Related topics

- [Enregistrement des commandes Artisan d'un package et résolution des dépendances](https://kawax.biz/fr/advanced/package-commands.md)
- [Enregistrement et mise en cache des écouteurs d'événements d'un package](https://kawax.biz/fr/advanced/package-events.md)
- [Enregistrement et cache des routes d'un package](https://kawax.biz/fr/advanced/package-routes.md)
- [Enregistrement et portée des middlewares d'un package](https://kawax.biz/fr/advanced/package-middleware.md)
- [Surcharge et mise à jour des traductions d'un package](https://kawax.biz/fr/advanced/package-translations.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.