Skip to content

Recurring Background Tasks with #[Scheduled] in Winter Boot

Winter Boot’s scheduling system lets you declare recurring tasks directly on service or component bean methods using the #[Scheduled] attribute. Rather than managing cron jobs externally, you embed timing logic inside your application code, and the framework handles process lifecycle and execution. Scheduled tasks run inside a dedicated worker process separate from the HTTP workers — this means a slow or long-running task never blocks web request handling. All you need is the Swoole extension and a single annotation on your application class to activate the feature.

Scheduling is powered by the Swoole PHP extension. Install and enable it before using #[Scheduled].

  1. Install the Swoole extension

    Terminal window
    pecl install swoole
  2. Enable Swoole in php.ini

    extension=swoole.so

Enable Scheduling on Your Application Class

Section titled “Enable Scheduling on Your Application Class”

Add #[EnableScheduling] to your #[WinterBootApplication] class. The framework validates at startup that #[WinterBootApplication] is also present and that Swoole is loaded — a TypeError or AnnotationException is raised if either condition is not met.

MyApplication.php
use dev\winterframework\stereotype\WinterBootApplication;
use dev\winterframework\stereotype\task\EnableScheduling;
use dev\winterframework\web\WinterWebSwooleApplication;
#[WinterBootApplication(scanPackages: ['com\example'])]
#[EnableScheduling]
class MyApplication
{
public static function main(): void
{
(new WinterWebSwooleApplication())->run(self::class);
}
}

Place #[Scheduled] on any public, non-final, non-abstract, zero-argument void method on a #[Service] or #[Component] bean. You must supply exactly one of the interval parameters (fixedDelay or fixedRate); combining conflicting options throws an AnnotationException at boot.

  • fixedDelay int Seconds to wait after the previous execution completes before starting the next. Use this when you want to avoid overlapping runs and the task duration may vary.

  • fixedDelayString string Property placeholder (e.g. ${my.delay}) that resolves to the fixedDelay value from your application configuration.

  • fixedRate int Seconds between the start of successive executions, regardless of how long each run takes. Use this when you need a consistent heartbeat.

  • fixedRateString string Property placeholder resolving to the fixedRate value from your application configuration.

  • initialDelay int Seconds to wait after application startup before the first execution fires.

  • initialDelayString string Property placeholder resolving to the initialDelay value from your application configuration.

The two core scheduling modes behave differently when a task takes longer than its interval. Choose the one that matches your task’s requirements.

fixedDelay introduces a gap between the end of one execution and the start of the next. Use this when you want to avoid overlapping runs and the task duration may vary.

CacheWarmer.php
use dev\winterframework\stereotype\Component;
use dev\winterframework\task\scheduling\stereotype\Scheduled;
#[Component]
class CacheWarmer
{
#[Scheduled(fixedDelay: 60, initialDelay: 10)]
public function refreshCache(): void
{
// Runs 10 seconds after startup, then again 60 seconds after
// each completion.
echo 'Cache refreshed at ' . date('H:i:s') . PHP_EOL;
}
}

Timeline: [startup] → 10s delay → run → 60s delay → run → 60s delay → run → …

Externalise Timing with Property Placeholders

Section titled “Externalise Timing with Property Placeholders”

Timing values can be externalised to application.yml using the String variants of each parameter. This lets you adjust schedules per environment without redeploying code.

application.yml
app:
scheduler:
reportDelay: 45
SomeScheduler.php
#[Component]
class SomeScheduler
{
#[Scheduled(fixedDelayString: '${app.scheduler.reportDelay}', initialDelay: 5)]
public function someScheduledMethodName(): void
{
echo 'I generate a unique ID every ' . getenv('app.scheduler.reportDelay') . ' seconds: ' . uniqid();
}
}

Configure the scheduling worker pool in application.yml under winter.task.scheduling:

application.yml
winter:
task:
scheduling:
poolSize: 1 # Number of dedicated scheduling worker processes
queueCapacity: 50 # Maximum concurrent scheduled requests that can be queued
  • poolSize int Total number of worker processes dedicated to executing scheduled tasks.
  • queueCapacity int Maximum number of concurrent scheduled task invocations that can be queued at once.

The following files show a full scheduling setup: the application entry point and a scheduler component with two differently-timed tasks.

MyApplication.php
use dev\winterframework\stereotype\WinterBootApplication;
use dev\winterframework\stereotype\task\EnableScheduling;
use dev\winterframework\web\WinterWebSwooleApplication;
#[WinterBootApplication(scanPackages: ['com\example'])]
#[EnableScheduling]
class MyApplication
{
public static function main(): void
{
(new WinterWebSwooleApplication())->run(self::class);
}
}
SomeScheduler.php
use dev\winterframework\stereotype\Component;
use dev\winterframework\stereotype\Autowired;
use dev\winterframework\task\scheduling\stereotype\Scheduled;
#[Component]
class SomeScheduler
{
#[Autowired]
private ReportRepository $reportRepository;
/**
* Generates a unique ID 10 seconds after startup,
* then every 20 seconds after each completion.
*/
#[Scheduled(fixedDelay: 20, initialDelay: 10)]
public function someScheduledMethodName(): void
{
echo 'I generate a unique ID every 20 seconds: ' . uniqid();
}
/**
* Persists a daily summary on a 24-hour fixed rate.
*/
#[Scheduled(fixedRate: 86400, initialDelay: 60)]
public function persistDailySummary(): void
{
$this->reportRepository->saveDailySummary(date('Y-m-d'));
}
}