Skip to main content
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 to package development. For how providers are loaded, see Package auto-discovery internals; for publishing configuration, see Package config merging and caching.

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.
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. 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.
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.
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, 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.
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.
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. 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.
SyncCatalogJob is an example job class provided by the package. For how to implement jobs, see Queues. 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. A design that keeps external connections and business logic out of constructors is also important in Registering package Artisan commands and resolving dependencies. 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.

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.
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 or a verification application. 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.

Task scheduling

Review run frequencies, setting up cron, delegating to queues, and operations during deployment.

Package service resolution hooks

Review applying hooks to unresolved and already resolved services, and caveats when re-registering.

Primary sources consulted

Last modified on November 1, 2026