Scheduler
The scheduler runs your methods on a schedule: every few seconds, every night at
three, on a cron expression. Marking the method with an attribute is enough — there
is no wrapper script to write and no line to add to the system crontab.
What the scheduler is and why
The scheduler is an application component that calls annotated methods itself at the right moments.
The problem. Routine work exists in almost every service: purge expired records,
send reminders, roll up a daily report, poll an external source. The classic answer
is system cron plus a wrapper script per task. The price:
- the schedule lives outside the project, in server configuration, and never reaches code review;
- every script boots PHP and the application afresh — container, configuration, connections;
- nobody watches for overlap: if the previous run is still going,
cronhappily starts a second; - the finest granularity is a minute, and no finer.
The solution. Winter keeps the schedule next to the code. A method is marked with an attribute, the scheduler starts once, and it calls tasks inside an already loaded application — with a ready container and open connections. Overlap is impossible, and the interval can be under a second.
The mechanism mirrors Spring’s @Scheduled: the same three ways to set the cadence
— fixedRate, fixedDelay and cron — with the same semantics. If you have used
it, what follows is mostly the details of cron expressions and behaviour during long
runs.
Quick start
Three steps: mark the method, enable the component, run it.
<?php
namespace Main\Task;
use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Schedule\Scheduled;
use Psr\Log\LoggerInterface;
class Cleanup
{
#[Autowired] private SessionRepository $sessions;
#[Autowired] private LoggerInterface $logger;
#[Scheduled(fixedRate: 300)] // every 5 minutes
public function purgeExpiredSessions(): void
{
$removed = $this->sessions->deleteExpired();
$this->logger->info('sessions purged', ['count' => $removed]);
}
}Enable the component in the application manifest:
#[EnableWeb]
#[EnableScheduler]
final class Application extends WinterApplication { /* ... */ }Check the task was found, then run:
php call schedule list
| TASK TRIGGER
| Main\Task\Cleanup::purgeExpiredSessions fixedRate 300s
| 1 task(s) defined.
php call run # the scheduler comes up with the applicationThe class needs no registration — the same scan that finds controllers finds it.
Requirements for the method
The scheduler builds the object through the container and calls the method with no arguments. Four conditions follow:
| Requirement | Why |
|---|---|
The method is public |
Otherwise it is invisible to the walk |
The method is not static |
The object is built by the container; the call is on an instance |
| No required parameters | There is nothing to pass in |
| The class is instantiable | Not abstract, not an interface |
Dependencies are injected as usual — through the constructor or #[Autowired];
everything on the
Dependency injection page applies.
Breaking any condition is an error at startup, not a silent skip:
ScheduleConfigException:
#[Scheduled] MainTaskCleanup::purge() must be a non-static method.Configuration errors fail the whole scan, so you see them immediately — including on
php call schedule list.
Triggers
The cadence is set by exactly one of three parameters. Two or more is an error; none is too.
| Parameter | What it means |
|---|---|
fixedRate |
Run every N seconds, counting from the start of the previous run |
fixedDelay |
Wait N seconds after the end of the previous run |
cron |
Run on a calendar schedule |
fixedRate — once every N seconds
Sets a frequency. A task starting at 12:00:00 with fixedRate: 60 goes again at
12:01:00 — whether it took a second or twenty.
#[Scheduled(fixedRate: 60)]
public function pollExchangeRates(): void { /* ... */ }Suits polling at a steady rate: metrics, exchange rates, the state of an external service.
fixedDelay — a pause between runs
Sets a rest interval. The pause is counted from the end of the previous run, so the actual period is “work + pause”.
#[Scheduled(fixedDelay: 10)]
public function drainQueue(): void { /* ... */ }Suits work where the system needs breathing room between heavy passes — draining a queue, processing a batch of files.
The difference is clearest in numbers. A run takes 3 seconds; the interval is 5 in both cases:
fixedRate 5: start ─3s─ end ──2s── start period 5 s
fixedDelay 5: start ─3s─ end ────5s──── start period 8 scron — by the calendar
For when the schedule is expressed not as a frequency but as a time of day or a day of the week.
#[Scheduled(cron: '0 3 * * *')] // every day at 03:00
public function nightlyReport(): void { /* ... */ }
#[Scheduled(cron: '30 6,18 * * 1-5')] // at 06:30 and 18:30 on weekdays
public function shiftSummary(): void { /* ... */ }An expression has exactly five fields:
minute hour day of month month day of week
0-59 0-23 1-31 1-12 0-7 (0 and 7 are Sunday)| Form | Example | Meaning |
|---|---|---|
| Any value | * |
Every minute / hour / day |
| An exact value | 30 |
At exactly 30 |
| A list | 6,18 |
At 6 and at 18 |
| A range | 1-5 |
Monday to Friday |
| A step | */5 |
Every five |
The macros @yearly, @monthly, @weekly, @daily (also @midnight) and
@hourly are supported.
What the expression does not have
This is not the full syntax found in other systems. Verified:
- No seconds — there are exactly five fields.
* * * * * *is a parse error. For intervals under a minute usefixedRate. - Names do not work —
MON,JANare rejected; fields are numeric only. - No
@reboot. - Precision is one minute, and the time is the server’s timezone.
Delaying the first run
initialDelay postpones the first run after the application starts. Useful so
that a heavy task does not begin while the service is still warming up.
#[Scheduled(fixedRate: 300, initialDelay: 60)]
public function warmCache(): void { /* ... */ }It does not combine with cron — that is a configuration error, not something
ignored.
Several schedules on one method
The attribute is repeatable: when one job has to happen for different reasons, attach several.
#[Scheduled(cron: '0 9 * * 1-5')] // weekday mornings
#[Scheduled(cron: '0 12 * * 6,0')] // weekend noon
public function sendDigest(): void { /* ... */ }Each attribute becomes an independent entry in the schedule.
How tasks execute
A task never overlaps itself. While a run is in progress the next one does not start, whatever the schedule says. You do not write locks for it.
Missed firings do not accumulate. If a run overran and swallowed several ticks, the task starts once when it is free, not once per missed tick. The scheduler catches up with the schedule rather than paying off a debt.
Tasks do not hold each other up. Every run goes in its own coroutine, so a slow task does not shift the timing of the others.
An exception inside a task does not kill the scheduler. It goes to the log at
error level and the schedule keeps running:
Scheduled MainTaskCleanup::purgeExpiredSessions threw: SQLSTATE[08006] ...A construction failure does not stop the schedule either
If the container could not build the task class, a message goes to the log
suggesting you check the constructor and #[Autowired], and the task keeps ticking
idly — the method simply is not called. That is visible only in the log, so after
adding a task it is worth confirming it ran at least once.
Control from the console
php call schedule list # which tasks were found and at what cadence
php call schedule start # run in the current terminal
php call schedule start -d # run in the background
php call schedule stop # graceful stop (SIGTERM)
php call schedule status # state and task count
php call schedule status -v # the same plus CPU and memory usagelist works with no running application — it is a static analysis of the project,
handy for checking after a schedule edit.
Starting it separately with call schedule start is for when the scheduler is
deployed on its own, without the web tier. If it is declared in the manifest,
php call run brings it up with the rest of the application and no separate command
is needed.
From code
The scheduler is a process, so the same control surface is available from the application:
use Flytachi\Winter\Kernel\Schedule\Stereotype\Scheduler;
Scheduler::dispatch(); // start in the background, returns the PID
Scheduler::status()?->activity; // is it working right now
Scheduler::stop(); // graceful stopIf you declared your own scheduler through
#[EnableScheduler(MyScheduler::class)], call these on your class. What status()
returns and which other operations exist is in
Control from code on the processes page.
One scheduler per host — and none per cluster
A second start on the same machine is blocked: the attempt receives
ProcessAlreadyRunningException. But there is no protection between machines. If
the application is rolled out to three pods, a scheduler comes up in each, and the
nightly report goes out three times.
The framework provides no cross-host coordination. Deploy the scheduler as a single instance — a separate headless deployment — or take a lock yourself, through a shared Redis for instance.
Examples
Draining a queue with breathing room
<?php
namespace Main\Task;
use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Schedule\Scheduled;
class OutboxSender
{
#[Autowired] private OutboxRepository $outbox;
#[Autowired] private Mailer $mailer;
#[Scheduled(fixedDelay: 5, initialDelay: 10)]
public function send(): void
{
foreach ($this->outbox->takePending(limit: 50) as $letter) {
$this->mailer->send($letter);
$this->outbox->markSent($letter->id);
}
}
}fixedDelay fits better than a rate here: while there are many letters the passes
follow one another with a five-second pause; when the queue is empty there is no
load.
A nightly routine in several steps
<?php
namespace Main\Task;
use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Schedule\Scheduled;
use Psr\Log\LoggerInterface;
class NightlyMaintenance
{
#[Autowired] private ReportService $reports;
#[Autowired] private ArchiveService $archive;
#[Autowired] private LoggerInterface $logger;
#[Scheduled(cron: '0 2 * * *')]
public function archiveOldOrders(): void
{
$moved = $this->archive->moveOlderThan('-1 year');
$this->logger->info('orders archived', ['count' => $moved]);
}
#[Scheduled(cron: '30 2 * * *')]
public function buildDailyReport(): void
{
$this->reports->buildFor(new \DateTimeImmutable('yesterday'));
}
#[Scheduled(cron: '0 3 * * 1')] // on Mondays
public function buildWeeklyReport(): void
{
$this->reports->buildWeekly();
}
}The steps are spread out in time deliberately: the scheduler guarantees no ordering between different tasks, so the “archive before report” dependency is expressed as an interval rather than as adjacency in the code. If the order is mandatory, merge the steps into one method.
A schedule that does not come from attributes
When the schedule has to live in a database and change without a deployment, override task discovery in your own scheduler:
<?php
namespace Main;
use Flytachi\Winter\Kernel\Schedule\ScheduledTask;
use Flytachi\Winter\Kernel\Schedule\Stereotype\Scheduler;
use Flytachi\Winter\Kernel\Schedule\Trigger\CronTrigger;
class DbScheduler extends Scheduler
{
/** @return ScheduledTask[] */
protected function discover(): array
{
$tasks = [];
foreach ($this->rules->all() as $rule) {
$tasks[] = new ScheduledTask(
className: $rule->class,
methodName: $rule->method,
trigger: new CronTrigger($rule->expression),
);
}
return $tasks;
}
}#[EnableScheduler(\Main\DbScheduler::class)]SIGHUP makes the scheduler re-read the schedule without stopping — for this
scenario that is the main way to apply changes.
Together with #[Async]
Both attributes can sit on one method, and the combination works. But it changes the scheduler’s behaviour more than it looks, so it deserves its own section.
#[Scheduled(fixedRate: 60)]
#[Async]
public function syncCatalog(): void { /* ... */ }What happens
The scheduler takes the task object from the container — and receives the
substituted class, the one #[Async] creates. So the method call goes into an
executor and returns immediately, before the work has even begun.
To the scheduler that looks like a task that finished instantly.
without #[Async]: start ──────── work ──────── end → next tick
with #[Async]: start ─ dispatch ─ end → next tick
└── the work proceeds on its own ────►What you gain
- A scheduler tick no longer waits for the task. A long run can no longer delay its neighbours — though the scheduler starts them independently anyway.
- A dedicated pool per task. Naming an executor limits this particular work
without touching the rest:
#[Async('pool.sync')].
What you lose
Overlap protection. This is the big one. A plain #[Scheduled] guarantees the
task will not start again until the previous run finished. With #[Async] the
scheduler considers the run over the moment it was dispatched — and on the next tick
happily starts it again while the previous one is still working.
A task with fixedRate: 60 that takes three minutes accumulates three concurrent
runs. After that, as many as the executor can bear.
The meaning of fixedDelay. The pause counts from “the end”, and the end is now
the dispatch. So fixedDelay stops differing from fixedRate: the promise to “wait
N seconds after the work finishes” no longer holds.
Rules for combining them
Only with a bounded pool
On the shared executor the number of concurrent runs is limited by nothing: the
scheduler will keep dispatching until the worker runs out of memory. When combining
#[Scheduled] with #[Async], always name your own pool with a limit.
#[Bean(name: 'pool.sync')]
public function syncPool(): ExecutorService
{
return Executors::newFixedExecutor(
concurrency: 2,
queue: 0, // no queue — reject the excess
onReject: RejectPolicy::DISCARD, // a skip beats a growing backlog
);
}#[Scheduled(fixedRate: 60)]
#[Async('pool.sync')]
public function syncCatalog(): void { /* ... */ }Choosing the reject policy here is choosing the behaviour under overload:
| Policy | What happens when a run cannot keep up |
|---|---|
DISCARD |
The extra start is skipped — closest to plain #[Scheduled] |
CALLER_RUNS |
The scheduler runs the task itself and waits — overlap is again impossible |
ABORT |
A RejectedExecutionException goes to the log — visible proof the schedule is falling behind |
When it is appropriate
Worth combining when the task is idempotent and overlap does no harm, while keeping up with the schedule matters: mailing independent recipients, refreshing a cache, polling several sources.
Not worth it when overlap is unacceptable — rolling up a report, a migration,
any work over shared state. Plain #[Scheduled] already gives you exactly the
guarantee you would otherwise have to build a lock for.
Check that `#[EnableAsync]` is on
Without it #[Async] has no effect, and the combination silently becomes an
ordinary scheduled task — with all of its guarantees. That is safe behaviour, but it
differs from what you intended and shows no sign of itself.
Next
- Components — where
#[EnableScheduler]is declared - Processes — the scheduler is built as a process and inherits its capabilities
- Daemons — when there is more work than one runner can manage
- Dependency injection — how a task receives services
- Async calls — executor pools and reject policies
- Logging — where task errors go