guikingone/scheduler-bundle
A Symfony bundle for scheduling recurring tasks and console commands with cron expressions. Configure tasks via attributes or YAML, run them with a worker, and use transports to control synchronous or background execution.
First, the honest framing: guikingone/scheduler-bundle is a Symfony bundle, not a Laravel package. There is no service provider, no config/schedule.php, and no Facade. You cannot composer require it into a Laravel app and start scheduling. A Laravel developer "leverages" this package in one of three ways:
Schedule is code-first and immutable at runtime — this bundle is not).If you're evaluating it for a pure Laravel codebase, stop at step 1 — the rest of this assessment assumes you'll actually run it inside Symfony (6.4/7.x).
composer require guikingone/scheduler-bundle
config/bundles.php.config/packages/scheduler.yaml). doctrine is the sane default for anything multi-host — it creates a task table you'll need to migrate.* * * * * cd /path/to/app && bin/console scheduler:run -q
The bundle does not run a daemon; scheduler:run is designed to be invoked every minute, and it decides which tasks are due based on their cron expression.
| File / symbol | Why |
|---|---|
SchedulerInterface |
The public API: schedule(), unschedule(), getTasks(), run() |
TaskInterface + Task/*Task.php |
The task taxonomy — read these before designing anything |
TransportInterface |
How tasks are persisted; the seam between code and runtime state |
SchedulePolicy/* |
Overlap/priority arbitration — the closest analogue to withoutOverlapping() |
Middleware/* |
Cross-cutting hooks around execution |
Event/* (Executing/Executed/Failed) |
Observability; where you'd ship metrics to your Laravel side |
Scheduling a console command from application code, rather than editing a crontab:
use SchedulerBundle\SchedulerInterface;
use SchedulerBundle\Task\CommandTask;
final class ReportScheduler
{
public function __construct(private SchedulerInterface $scheduler) {}
public function register(): void
{
$this->scheduler->schedule(
new CommandTask('nightly-report', 'app:report:generate', '0 2 * * *')
);
}
}
The task is written to the transport, picked up on the next scheduler:run tick, and survives deploys — which is both the feature and the governance risk.
The bundle supports both, and mixing them is the intended workflow:
kernel.boot listener, or a fixture command): versioned, reviewable, reproducible. Use this for anything whose existence is a business invariant.$scheduler->schedule()): lets ops add/remove recurring work without a deploy. Use sparingly and behind an audit trail.A workable discipline: code-declared tasks are idempotent-on-boot (check getTasks() before scheduling), runtime tasks are namespaced (ops:) so you can bulk-remove them.
| Task | Use when |
|---|---|
CommandTask |
The work is already a Symfony console command you can test standalone |
CallbackTask |
Small glue in an existing service; not serializable across hosts |
ShellTask |
Wrapping an external binary — treat as an injection surface, never interpolate user input |
HttpTask / MessageTask |
This is your Laravel bridge: hit a Laravel endpoint or dispatch to a shared queue rather than reimplementing logic |
For a Laravel shop, MessageTask (Symfony Messenger) or HttpTask pointing at a Laravel route are the two integrations worth prototyping. Both keep the scheduler dumb and let Laravel own the actual job, retries, and Horizon visibility.
Laravel gives you withoutOverlapping() and onOneServer() for free. Here you opt in via SchedulePolicy (ExecutionTimePolicy, IdlePolicy, numeric ordering) and middleware. Design assumption to internalize: a task taking longer than its interval will be re-evaluated on the next tick, so either bound the runtime or attach a lock middleware. If you're porting a Laravel schedule over, audit every ->withoutOverlapping() you had and make it explicit.
Middleware is the extension point for: distributed locking, structured logging, backoff/retry, tenant scoping, and "skip if a dependency is down." This is where you'd add the equivalent of Laravel's ->before()/->after()/->onFailure() callbacks. Keep them small and stateless — they wrap every task.
TaskExecutingEvent / TaskExecutedEvent / TaskFailedEvent are the honest seam. A realistic setup: a listener that emits to your existing telemetry (OTLP, StatsD, or an HTTP call into the Laravel app's monitoring endpoint). Don't build a second dashboard — forward the signal.
| Laravel | SchedulerBundle equivalent |
|---|---|
$schedule->command('x')->daily() |
new CommandTask('x', 'app:x', '0 0 * * *') |
->withoutOverlapping() |
SchedulePolicy / lock middleware |
->onOneServer() |
Doctrine/Redis transport + policy (no free lunch) |
Schedule::call(fn() => …) |
CallbackTask (single-host only) |
->runInBackground() |
Not native; use MessageTask or ShellTask & |
schedule:list |
scheduler:list |
->timezone('UTC') |
Config-level TZ; per-task TZ is not a first-class concept |
Schedule::job(new X) |
MessageTask + Messenger |
If you must run both stacks, make the transport the contract, not the code. The cleanest version: the Symfony scheduler only ever emits messages/HTTP calls; the Laravel app owns idempotency keys. Avoid the temptation to have Laravel write directly into the Doctrine task table — the schema and internal state machine are the bundle's private business.
Transport choice is the whole ballgame.
filesystem transport is fine for a single VM and catastrophic on multiple hosts (split brain, duplicate runs).doctrine transport means schema migrations you don't control the cadence of — pin the bundle version and review migration diffs on upgrade.redis is the right call for ephemeral/one-shot work, worse for anything you need to audit.No overlap protection by default. This is the #1 difference from Laravel. A 90-second task on a * * * * * expression will stack. Always pair long tasks with a lock middleware or a SchedulePolicy.
The cron entry is load-bearing. If scheduler:run isn't firing every minute, nothing fires, and there's no "missed tick" replay. Add an external heartbeat check on the last successful run timestamp — the bundle won't tell you it stopped.
Timezones. Expressions are evaluated in the PHP/server timezone unless configured. If your Laravel app uses APP_TIMEZONE=UTC and the Symfony box is local time, your "nightly" job drifts by hours and nobody notices for a week.
Runtime tasks are invisible to code review. Anything scheduled at runtime lives only in the transport. Provide a scheduler:list-based export (or a CI job that diffs it) so a task added by hand in production doesn't become folklore.
Deployment ordering. Task class names are serialized into the transport. Rename or namespace-move a CommandTask subclass and existing rows break. Treat task names as a public contract; keep them as plain strings you control.
Debugging workflow. scheduler:list → confirm the task exists and its expression is what you think → check the task's state in the transport (failed tasks can stay failed) → tail the app log filtered on the task name → only then reach for -vvv on scheduler:run. If a task never appears in scheduler:list, the problem is registration, not execution.
Version churn. The package is pre-1.0 in spirit despite recent releases; task/transport APIs have moved between minors. Pin to an exact minor, and budget an hour to read the UPGRADE notes each bump. With 116 stars and ~0 dependents, you are effectively the integration maintainer — there is no Stack Overflow corpus to lean on. The source is readable; read it.
Extension points worth knowing exist before you need them: custom TaskInterface implementations (wrap your own job abstraction), custom TransportInterface (e.g., an existing queue you already operate), middleware (locking, retry, tenancy), SchedulePolicy (priority/fairness), and event listeners (telemetry). Almost any "the bundle doesn't do X" complaint resolves at one of these seams.
When not to use it. If your needs are "these five commands run on a fixed cadence" and you already run Laravel, this bundle adds a transport, a schema, a crontab, and a second framework for no additional capability. The value only appears when tasks must be created and inspected at runtime by non-developers, or when you're already in Symfony. Be honest about which situation you're in before adopting it.
How can I help you explore Laravel packages today?