zenstruck/schedule-bundle
Symfony bundle for scheduling repetitive tasks and console commands with a single cron entry. Define schedules in PHP with flexible frequencies, lock protection, and output logging, powered by Symfony Messenger.
First, the important framing: zenstruck/schedule-bundle is a Symfony bundle, not a Laravel package. It has no service provider, no config/schedule.php, and cannot be installed into a Laravel app (it requires symfony/framework-bundle and the Symfony Scheduler/Messenger stack). So day-to-day you'll use it when you're in a Symfony codebase — or when you're contributing to a package that must run under both. In Laravel itself the equivalent is Illuminate\Console\Scheduling\Schedule; the patterns below map almost 1:1, which is why the conceptual transfer is worth the read.
Minimal steps in a Symfony app:
composer require zenstruck/schedule-bundle (v1.9.0 adds Symfony 8 support, so it resolves cleanly on Symfony 6/7/8 — Symfony 8 only if your PHP is new enough; otherwise Composer picks the older framework, no forced upgrade).Zenstruck\ScheduleBundle\ZenstruckScheduleBundle in config/bundles.php.final class MainSchedule implements ScheduleProvider
{
public function schedule(Schedule $schedule): void
{
$schedule->addCommand('app:prune', ['--days' => 30])->daily()->at('03:00');
$schedule->addCallback(fn () => /* ... */)->every('5 minutes')->withoutOverlapping();
}
}
php bin/console schedule:list --detail to confirm what's registered and when it next runs.Where to look first: the Schedule builder API (addCommand, addCallback, frequency helpers, constraints), then config/packages/zenstruck_schedule.yaml for lock and monitor settings. First real use case: moving a handful of crontab lines into version-controlled PHP.
One provider per domain. app:schedule:orders, app:schedule:billing, etc. Providers are autoconfigured via the schedule.provider tag, so a class implementing ScheduleProvider in src/ is picked up with no wiring. Small providers keep schedule:list readable and let you gate registration by environment.
Two execution models, pick deliberately.
schedule:run — synchronous: due tasks execute in the command process. Simplest, closest to cron, easiest to debug.schedule:monitor + Messenger — the monitor runs every minute, dispatches RunCommandMessage / RunCallbackMessage onto a bus, and a worker executes them. This gives you retries, failure transport, and parallelism for free, but only if a worker is actually running.Frequency and constraints compose. Build with every('5 minutes'), daily()->at('03:00'), cron('0 3 * * *'), then attach ->when(fn () => $this->isBusinessDay()), ->withoutOverlapping(), ->timezone('Europe/Paris'). Constraints are evaluated at dispatch time, so they read as guard clauses rather than control flow.
Laravel mapping (for muscle memory):
| This bundle | Laravel Scheduler |
|---|---|
addCommand('app:foo') |
$schedule->command('app:foo') |
addCallback($fn) |
$schedule->call($fn) |
->every('5 minutes') |
->everyFiveMinutes() |
->withoutOverlapping() |
->withoutOverlapping() |
->timezone('...') |
->timezone('...') |
schedule:monitor + worker |
schedule:run from cron |
If you're porting, keep the provider-per-domain split — it survives the move and is better than one fat Kernel::schedule().
Integration tip: route the monitor's bus to your async transport explicitly (zenstruck_schedule.monitor.bus) rather than relying on the default bus, otherwise production traffic silently rides your sync transport.
The silent-queue trap. With schedule:monitor, tasks are dispatched, not executed. No Messenger worker consuming the configured bus = nothing runs, no error, and the monitor exits 0 every minute. First debugging step whenever "the schedule isn't firing": check worker processes and the failure transport before touching the schedule definitions.
Locks are host-local by default. withoutOverlapping() uses the configured lock factory, which is file/flock-based out of the box. On multi-host or containerised setups that directory isn't shared, so overlap protection quietly does nothing. Point the lock at shared storage or a distributed factory, and verify by running two monitors concurrently on purpose.
Timezone is a two-layer problem. The schedule has its own timezone; the server (and cron) has another. ->at('03:00') means 03:00 in the schedule's timezone — if that's inherited from php.ini/app config rather than set explicitly, DST transitions will bite you twice a year.
schedule:monitor must be cron'd every minute. It is not a daemon. A common misconfiguration is running it hourly "to save resources", which makes every sub-hourly task fire late at best.
Debugging commands: schedule:list --detail for definitions, schedule:run --force to bypass the lock while iterating, and --verbose to see what was actually dispatched. For callbacks, wrap the body in try/catch and log — an uncaught exception in a callback surfaces as a failed message, which is one hop removed from where you're looking.
Upgrading to v1.9.0 is safe. The release is Symfony 8 support, deprecation fixes (from 1.8.1), a PHPStan baseline refresh, and dev-dependency bumps. No API changes, no deprecations introduced, nothing to migrate. It's a straight composer update — worth doing specifically for the deprecation cleanups if you're already planning a Symfony 8 move, since forward-compatible code here removes one blocker later. The only meaningful constraint is transitive: Symfony 8 requires a recent PHP, so confirm your CI matrix before assuming the bump applies everywhere.
Extension points worth knowing: custom ScheduleProvider implementations (autoconfigured), your own Task subclasses for reusable task shapes, and a swapped lock factory. There is no event system to hook into around task execution — if you need cross-cutting behaviour (metrics, audit logging), wrap it in the callback/command itself rather than looking for a listener.
How can I help you explore Laravel packages today?