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

# Registering and maintaining scheduled tasks in packages

> Examine Laravel 13's Schedule and post-resolution hooks to understand designing package scheduled tasks that are explicitly enabled, duplicate registration, lock names, configuration changes, and testing.

When you add periodic synchronization or a heartbeat to a package, separate **providing the work** from **registering it on the schedule**. Design it so that merely installing the package does not add scheduled work to the consuming application, and so that users can choose whether to enable it, how often it runs, and how it behaves across multiple servers.

This page checks the official source of Laravel Framework `v13.39.0` and applies the basics of [Task scheduling](https://kawax.biz/en/scheduling.md) to package development. For how providers are loaded, see [Package auto-discovery internals](https://kawax.biz/en/advanced/package-discovery.md); for publishing configuration, see [Package config merging and caching](https://kawax.biz/en/advanced/package-config-merging.md).

## Register after Schedule is resolved

In a typical Laravel application, `FoundationServiceProvider` registers `Schedule::class` as a singleton. Its factory calls the Console Kernel's `resolveConsoleSchedule()`, which applies the application's timezone and the scheduler's cache store.

A package should extend this shared Schedule rather than creating its own `new Schedule()` and stopping there. The following provider assumes that `Acme\Courier\` maps to `src/` via Composer PSR-4 and that the package has a `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()` waits for future resolution if the service is not yet resolved, and if it is already resolved, it retrieves the instance and invokes the callback immediately. This lets you register even if another provider has already used Schedule. For details, see [Package service resolution hooks](https://kawax.biz/en/advanced/package-service-resolution.md).

`runningInConsole()` does not detect a specific Artisan command. In this example it excludes uses of Schedule over HTTP. Also, do not make the provider that registers the hook a deferred provider. If `boot()` does not run by the time it is needed, the hook will not be registered either.

```mermaid theme={null}
flowchart TD
    A["Package provider boot()"] --> B["Register post-resolution hook for Schedule"]
    B --> C{"Is Schedule resolved?"}
    C -->|No| D["Wait for first resolution"]
    C -->|Yes| E["Retrieve existing singleton"]
    D --> F["Check enabled setting and registration state"]
    E --> F
    F -->|Enabled and not registered| G["Add event definition"]
    F -->|Disabled or already registered| H["Do not add"]
    G --> I["Later schedule:run<br>evaluates run conditions"]
```

> [!NOTE]
>
> Registering an event and running a task are separate. Loading this provider does not run the heartbeat on the spot. Registration alone does not start cron or `schedule:work` either.

## Let users enable it explicitly

The package's `config/courier.php` is disabled by default.

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

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

In the consuming application, publish or create the configuration and change `schedule.enabled` to `true`. In this example, the values are read at registration time. The timezone defaults to UTC; if you choose a different timezone, also account for runs being duplicated or skipped due to daylight saving time.

Because configuration is [merged shallowly](https://kawax.biz/en/advanced/package-config-merging.md), the registrar also specifies a default for each key. Even if a partial override removes the other keys from the `schedule` array, registration can proceed with the base settings.

## Prevent duplicate registration on the same Schedule

The following `src/CourierSchedule.php` registers a minimal heartbeat that writes a single log entry. A `WeakMap` ensures that **this registrar adds to the same Schedule object only once**.

```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;
    }
}
```

Because this registrar is a singleton, retrieving it again from the provider's hook shares the registration state. A new Schedule, on the other hand, can still be registered. Avoid a process-wide static "registered" flag that would also exclude Schedules in new containers or tests.

`Schedule::call()`, `exec()`, and `job()` append events to an array. They are not APIs that find and replace an event with the same name or deduplicate events.

> [!WARNING]
>
> `withoutOverlapping()` does not fix duplicate registration. If the same task is registered twice, the later event can still run once the earlier run finishes and releases the lock. `onOneServer()` is not a feature for removing duplicate registrations either.
>
> The WeakMap above only prevents duplicates from this registrar. If users add the same task to `routes/console.php`, disable the package's automatic registration and keep responsibility for registration in a single place.

## Event names also affect lock compatibility

`name()` is an alias for `description()`, but its effect on the lock name differs by event type. The following are the default mutex names.

| Event | Value the mutex name is derived from | Role of `name()` |
| - | - | - |
| Event from `command()` / `exec()` | Cron expression and normalized command string | Description for display. Not included in the default mutex name |
| CallbackEvent from `call()` / `job()` | The event's description | Display name and lock identifier. The cron expression is not included |

For a CallbackEvent, assign a **non-empty name before** `withoutOverlapping()` or `onOneServer()`. If no name is set, these methods throw an exception.

`job()` normally uses something like the job class name as the initial name. However, when registering the same job with different arguments or targets, give each target an explicit name. Changing only the arguments does not automatically give the initial name a different value.

```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` is an example job class provided by the package. For how to implement jobs, see [Queues](https://kawax.biz/en/queues.md).

Changing the name, command, or cron expression also changes the lock identifier, depending on the event type. During a deployment where old and new versions of processes coexist, the same work may acquire different locks. Do not treat this like a mere change to display text; manage task names as a stable public specification.

### Think about onOneServer and withoutOverlapping separately

* `onOneServer()` selects which server is responsible for the run at that scheduled time. The `CacheSchedulingMutex` we examined appends the hour and minute to the event's mutex name and holds it with a one-hour expiration. This is separate from the overlap-prevention lock that is released when the task finishes.
* `withoutOverlapping()` prevents overlap while a previous run is still in progress. The expiration you specify is in minutes, and the default is 1,440 minutes. If the expiration is too short, the lock expires before the work finishes.
* When `job()` dispatches to a queue, completion of the scheduled event does not mean the job has finished processing. Design overlap control on the worker separately, for example with unique jobs or the `WithoutOverlapping` middleware on the job side.

For users who enable `onOneServer()`, document a shared cache using `database`, `memcached`, `dynamodb`, or `redis`, as specified by the official documentation, as a requirement. Locks do not guarantee exactly-once execution of business logic, so the work must also be safe to run again.

## Resolve heavy dependencies at run time

The `LoggerInterface` in the heartbeat above is resolved when the event runs, by `CallbackEvent` using the container's `call()`. This setup does not write logs or create clients when the hook is registered.

| Definition method | What to watch for at registration |
| - | - |
| Passing a closure to `call()` | The body is called later. However, objects created outside and captured are already constructed |
| `call(new Task)` | `new Task` runs at registration time |
| `command(CommandClass::class)` | Schedule creates the class through the container to obtain its name and description |
| `job(JobClass::class)` | Job creation waits until run time, inside the event's callback |

A design that keeps external connections and business logic out of constructors is also important in [Registering package Artisan commands and resolving dependencies](https://kawax.biz/en/advanced/package-commands.md).

Also, because `call()` and `job()` create CallbackEvents, `runInBackground()` cannot be used with them. Delegate long-running work to a queue, or choose background execution with `command()` if needed.

## Do not take over application-wide settings

`Schedule::useCache()` changes the store of the mutex objects held by Schedule. It is not a setting scoped to the package's events only; it also affects events that share the same mutex. Do not call it unconditionally in the package's `boot()`; treat it as a setting the consuming application chooses.

Likewise, the package should not unilaterally change `Schedule::alwaysOnOneServer()`, pausing the scheduler, or the policy for running during maintenance mode.

## Create a new Schedule after configuration changes

In the example, `enabled`, the cron expression, and the timezone are registration-time settings. Simply changing `config()` values against an already created Schedule does not add or remove events or reload settings. The WeakMap check is not meant for reconfiguration either.

For deployments that use the configuration cache, rebuild the cache and check `schedule:list` in a process that loads the new configuration. Also verify that long-running schedulers, and `schedule:run` processes running sub-minute tasks, do not keep old code or old configuration around. For interrupting sub-minute tasks during deployment, see [Task scheduling](https://kawax.biz/en/scheduling.md).

## Test registration separately from execution

The following is an example `tests/CourierScheduleTest.php` that uses the package's Composer autoloading and PHPUnit. It replaces the mutexes with test doubles and verifies the registration result without an external cache or real 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);
    }
}
```

This test is a unit test of the registrar. Verify provider auto-discovery, configuration merging, and integration with the Console Kernel separately with [Orchestra Testbench](https://kawax.biz/en/advanced/package-testing.md) or a verification application.

| Case to verify | Completion criteria |
| - | - |
| Installed with default settings | The package's scheduled tasks are not registered |
| Enabled, then `schedule:list` | Exactly one event exists, with the correct frequency and timezone |
| Provider boots before and after Schedule is resolved | Registration takes effect in both cases |
| Registration only | No business services are created and no work begins |
| Run the task body on its own | Required dependencies are resolved, and the expected work and exceptions can be verified |
| Multiple servers and long-running work | Verify the shared cache requirement and the two kinds of locks separately |
| Updates with configuration cache | No old tasks or duplicate registrations remain in new processes |

Do not run all of the consuming application's tasks with `schedule:run` just to make the registration tests pass. Separating tests of the task body from scheduler integration tests in a controlled environment avoids side effects from unrelated scheduled work.

## Related pages

> [card] [Task scheduling](https://kawax.biz/en/scheduling.md)
>
> Review run frequencies, setting up cron, delegating to queues, and operations during deployment.

> [card] [Package service resolution hooks](https://kawax.biz/en/advanced/package-service-resolution.md)
>
> Review applying hooks to unresolved and already resolved services, and caveats when re-registering.

## Primary sources consulted

* [Laravel official documentation: Task scheduling](https://github.com/laravel/docs/blob/960eedf0911155ad16326ade49c0ea5d18eda0b8/scheduling.md)
* [Laravel official documentation: Package development](https://github.com/laravel/docs/blob/960eedf0911155ad16326ade49c0ea5d18eda0b8/packages.md)
* [FoundationServiceProvider: registering the Schedule singleton](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php)
* [Console Kernel: creating Schedule and default settings](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: adding events, lazy job creation, and sharing mutexes](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/Schedule.php)
* [Event: mutex names for command events](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/Event.php)
* [CallbackEvent: name requirement and run-time dependency resolution](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/CallbackEvent.php)
* [CacheEventMutex: overlap-prevention locks and expiration conversion](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/CacheEventMutex.php)
* [CacheSchedulingMutex: per-run-time locks](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/CacheSchedulingMutex.php)
* [Official tests: event registration and timezones](https://github.com/laravel/framework/blob/v13.39.0/tests/Console/ConsoleEventSchedulerTest.php)
* [Official tests: lazy creation from job class names](https://github.com/laravel/framework/blob/v13.39.0/tests/Console/Scheduling/ScheduleTest.php)


## Related topics

- [Task scheduling](https://kawax.biz/en/scheduling.md)
- [Registering package Artisan commands and resolving dependencies](https://kawax.biz/en/advanced/package-commands.md)
- [Laravel Package Development](https://kawax.biz/en/advanced/package-development.md)
- [RPC (Remote Procedure Call)](https://kawax.biz/en/packages/laravel-copilot-sdk/rpc.md)
- [Authentication methods - Laravel Bluesky](https://kawax.biz/en/packages/laravel-bluesky/authentication.md)


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