Tick is a PHP task scheduler. One tick per cron minute. No daemon, no framework, no surprises.
A modern, framework-agnostic PHP 8.2+ task scheduler library with strict typing, PSR-3 / PSR-20 interfaces, optional Symfony Console command classes, and first-class support for locking, retry policies, and runtime constraints. Designed for Slim, Mezzio, and DevOps-oriented teams who want explicit control over their scheduled tasks without framework coupling or hidden globals.
For an architectural overview of Tick's components and their runtime interactions, see docs/architecture.md.
- Pure library, no daemon, no framework coupling: The scheduler is invoked externally (typically via cron). No long-running process, no framework-specific integrations.
- PSR-3 / PSR-20 interfaces only: Logging and time are abstracted via standard interfaces, enabling easy testing and integration.
- Pluggable behavior via small, single-purpose interfaces: Tick ships
LockInterface(FileLock),TaskInterface(CallableTask,RawTask),RetryPolicyInterface(5 built-in policies), andTaskDispatcherInterface(default, dry-run, timing decorator). Every concern that users may need to swap or decorate is an interface — bring your own Redis lock, queue-pushing task, or custom retry policy without forking the library. - Fluent builder with once-only guards: Calling
cron()/ sugar methods,withoutOverlapping(),retry(), or constraint methods more than once throwsInvalidScheduleException. This prevents accidental misconfiguration. - Name is supplied at registration, not inside the task: Tasks are reusable; the name is a property of the registration, not of the task object.
The library ships:
Schedulerclass with three explicit registration methods:call(),invoke(),raw()- Two built-in task adapters (
CallableTask,RawTask) plus the openTaskInterfacecontract - Cron expression parsing (via
dragonmantank/cron-expression) - File-based locking (
FileLock) withflock()semantics - Retry policies (
NoRetryPolicy,ConstantBackoffRetryPolicy,LinearBackoffRetryPolicy,JitteredExponentialBackoffRetryPolicy,DecorrelatedJitterRetryPolicy) - Runtime constraints (
when(),skip()) - Dry-run dispatcher and timing decorator
- Four optional Symfony Console command classes (
RunCommand,ListTasksCommand,RunTaskCommand,UpcomingCommand)
You provide:
- Composition root that builds the
Schedulerand registers tasks - Console wiring — if you want the CLI commands, you instantiate
Symfony\Component\Console\Applicationand add the four command classes yourself (no binary or autodiscovery is shipped) - Container setup (PHP-DI, Pimple, or manual wiring) — only if you use containers
- Task implementations (your business logic, as closures or
TaskInterfaceobjects) - Crontab entry to invoke the scheduler every minute
- Custom
LockInterfaceif you need Redis/database locks
composer require nemanjajojic/tickThis gives you the Scheduler, all task adapters, dispatchers, locks, and retry policies. Zero framework dependencies. Use this if you call $scheduler->run() directly from your own application (Slim middleware, a Laravel command, a cron-invoked PHP script, etc.).
composer require nemanjajojic/tick symfony/consoleThis makes the four command classes (RunCommand, ListTasksCommand, RunTaskCommand, UpcomingCommand) usable. Tick does not ship a binary or an Application factory — you instantiate Symfony\Component\Console\Application and add the commands yourself. See Pattern 2 below for a complete bin/ script. symfony/console is a suggested dependency, not a hard one. The CLI is convenience, not the product; you can use the entire scheduler without it.
- PHP 8.2+
- Composer 2
The Scheduler provides three explicit registration methods. Each requires a name as the first argument.
<?php
declare(strict_types=1);
use DateTimeZone;
use NemanjaJojic\Tick\Clock\SystemClock;
use NemanjaJojic\Tick\Lock\FileLock;
use NemanjaJojic\Tick\Scheduler;
use NemanjaJojic\Tick\Task\Dispatcher\TaskDispatcher;
$dispatcher = new TaskDispatcher(new FileLock('/var/run/scheduler'));
$scheduler = new Scheduler($dispatcher, new SystemClock(), new DateTimeZone('Europe/Belgrade'));
// 1. Closure: inline logic with explicit name
$scheduler->call('cleanup', static fn (): void => unlink('/tmp/cache.tmp'))
->daily()
->describe('Removes the temporary cache.');
// 2. Invokable: container-resolved TaskInterface instance
$scheduler->invoke('reports', $container->get(GenerateReports::class))
->cron('*/15 * * * *');
// 3. Raw: external command as array (no shell interpolation).
// Works for any binary or interpreter — PHP, Python, Go, shell, etc.
$scheduler->raw('backup', ['rsync', '-a', 'src/', 'dst/'])
->hourly();
$scheduler->raw('etl', ['/usr/bin/python3', __DIR__ . '/etl.py', '--full'])
->daily();
$scheduler->raw('migrate', [PHP_BINARY, __DIR__ . '/migrate.php', '--force'])
->daily();
$scheduler->run();There are three integration patterns, from least to most CLI tooling. All three build a Scheduler in user code; Tick does not impose a bootstrap-file convention.
- Pure library — call
$scheduler->run()from your own PHP entry point. Nosymfony/console, no command classes. - Standalone Symfony Console application — install
symfony/console, add the three Tick command classes to your ownApplication, invoke via cron. - Framework command bus — register the three Tick command classes as services in your existing Symfony / Laravel / Mezzio app and let your framework's console front-controller run them.
The smallest possible deployment: a single PHP script that builds a Scheduler, registers tasks, and calls run(). Invoked once per minute from cron. No bootstrap indirection, no symfony/console, no framework.
Create cron/scheduler.php:
<?php
// cron/scheduler.php
declare(strict_types=1);
use DateTimeZone;
use NemanjaJojic\Tick\Clock\SystemClock;
use NemanjaJojic\Tick\Lock\FileLock;
use NemanjaJojic\Tick\Retry\JitteredExponentialBackoffRetryPolicy;
use NemanjaJojic\Tick\Scheduler;
use NemanjaJojic\Tick\Task\Dispatcher\TaskDispatcher;
require __DIR__ . '/../vendor/autoload.php';
$scheduler = new Scheduler(
new TaskDispatcher(new FileLock(__DIR__ . '/../var/locks')),
new SystemClock(),
new DateTimeZone('Europe/Belgrade'),
);
$scheduler->call('cleanup-temp', static function (): void {
array_map('unlink', glob('/tmp/myapp-*.tmp') ?: []);
})
->hourly();
$scheduler->invoke('generate-reports', new GenerateReports())
->dailyAt('06:00');
$scheduler->raw('backup', ['rsync', '-a', '/var/data/', '/mnt/backup/'])
->dailyAt('02:00')
->withoutOverlapping(ttlSeconds: 7200);
$scheduler->raw('etl', ['/usr/bin/python3', __DIR__ . '/../scripts/etl.py'])
->everyNMinutes(15)
->retry(new JitteredExponentialBackoffRetryPolicy(
maxAttempts: 3,
baseDelayMs: 500,
maxDelayMs: 5000,
));
$scheduler->run();GenerateReports is any class implementing NemanjaJojic\Tick\Task\TaskInterface:
<?php
declare(strict_types=1);
use NemanjaJojic\Tick\Task\TaskInterface;
final readonly class GenerateReports implements TaskInterface
{
public function __construct() {
}
public function __invoke(): void
{
// your business logic here
}
}Crontab entry:
* * * * * cd /var/www/myapp && php cron/scheduler.php >> var/log/scheduler.log 2>&1That is the entire integration. No symfony/console, no command classes, no CLI binary. This pattern suits:
- Small services where the scheduler is one of a few simple cron entries
- Projects that already use a non-Symfony framework (Slim, Mezzio, plain PHP) and don't want a second console
- Environments where pulling in additional dependencies is undesirable
- CI/CD pipelines where you want to drive a single tick from a shell script or test
When you want a proper CLI with tick:list, tick:run-task, and tick:upcoming for ops and debugging, install symfony/console, build your own Application, and add Tick's four command classes. There is no vendor/bin/tick and no --bootstrap option — you own the entry point and the wiring.
Create bin/my-scheduler:
<?php
// bin/my-scheduler
declare(strict_types=1);
use DateTimeZone;
use DI\ContainerBuilder;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use NemanjaJojic\Tick\Clock\SystemClock;
use NemanjaJojic\Tick\Console\ListTasksCommand;
use NemanjaJojic\Tick\Console\RunCommand;
use NemanjaJojic\Tick\Console\RunTaskCommand;
use NemanjaJojic\Tick\Console\UpcomingCommand;
use NemanjaJojic\Tick\Lock\FileLock;
use NemanjaJojic\Tick\Retry\JitteredExponentialBackoffRetryPolicy;
use NemanjaJojic\Tick\Scheduler;
use NemanjaJojic\Tick\Task\Dispatcher\DryRunTaskDispatcher;
use NemanjaJojic\Tick\Task\Dispatcher\LoggingTaskDispatcherDecorator;
use NemanjaJojic\Tick\Task\Dispatcher\TaskDispatcher;
use Symfony\Component\Console\Application;
require __DIR__ . '/../vendor/autoload.php';
// Build container (same one your Slim / Mezzio app uses)
$containerBuilder = new ContainerBuilder();
$containerBuilder->addDefinitions(__DIR__ . '/../config/container.php');
$container = $containerBuilder->build();
// Logger
$logger = new Logger('scheduler');
$logger->pushHandler(new StreamHandler(__DIR__ . '/../var/log/scheduler.log', Logger::INFO));
// Dispatcher: swap for dry-run via environment variable.
// Wrap with LoggingTaskDispatcherDecorator to log every dispatch outcome via PSR-3.
$isDryRun = 'true' === getenv('SCHEDULER_DRY_RUN');
$innerDispatcher = true === $isDryRun
? new DryRunTaskDispatcher()
: new TaskDispatcher(new FileLock(__DIR__ . '/../var/locks'));
$dispatcher = new LoggingTaskDispatcherDecorator($innerDispatcher, $logger);
$scheduler = new Scheduler(
$dispatcher,
new SystemClock(),
new DateTimeZone('Europe/Belgrade'),
);
// Container-resolved task: PHP-DI does the wiring
$scheduler->invoke('daily-report', $container->get(App\Task\DailyReportTask::class))
->dailyAt('08:00')
->withoutOverlapping();
$scheduler->invoke('sync-inventory', $container->get(App\Task\SyncInventoryTask::class))
->everyNMinutes(5)
->retry(new JitteredExponentialBackoffRetryPolicy(
maxAttempts: 3,
baseDelayMs: 500,
maxDelayMs: 5000,
));
$scheduler->call('cleanup-temp', static function () use ($container): void {
$container->get(App\Service\TempFileService::class)->cleanup();
})
->hourly()
->skip(static fn (): bool => 'maintenance' === getenv('APP_MODE'));
$application = new Application('my-scheduler', '1.0.0');
$application->add(new RunCommand($scheduler));
$application->add(new ListTasksCommand($scheduler));
$application->add(new RunTaskCommand($scheduler));
$application->add(new UpcomingCommand($scheduler));
$application->run();Make the script executable:
chmod +x bin/my-schedulerCrontab entry:
* * * * * cd /var/www/myapp && php bin/my-scheduler tick:run >> var/log/scheduler.log 2>&1This pattern suits:
- Slim / Mezzio / Laminas apps with a real DI container
- Teams that want
tick:listandtick:run-task <name>for ops and debugging - Multi-environment setups (
bin/my-scheduler-staging,bin/my-scheduler-prod) - Projects already invested in
symfony/consolefor other CLI tooling
Slim is an HTTP micro-framework and intentionally ships no console layer. The idiomatic way to "register Tick as services" in a Slim app is to reuse the same PHP-DI container Slim already uses for HTTP, and let the container build both the Scheduler and the three command classes. A thin bin/console entry point then turns container services into a Symfony Application. There is no Tick-specific bootstrap and no per-command construction in user code.
Define the services once in your existing PHP-DI definitions file (config/container.php or wherever Slim loads them):
<?php
// config/container.php
declare(strict_types=1);
use DateTimeZone;
use NemanjaJojic\Tick\Clock\SystemClock;
use NemanjaJojic\Tick\Console\ListTasksCommand;
use NemanjaJojic\Tick\Console\RunCommand;
use NemanjaJojic\Tick\Console\RunTaskCommand;
use NemanjaJojic\Tick\Console\UpcomingCommand;
use NemanjaJojic\Tick\Lock\FileLock;
use NemanjaJojic\Tick\Scheduler;
use NemanjaJojic\Tick\Task\Dispatcher\TaskDispatcher;
use Psr\Container\ContainerInterface;
use function DI\autowire;
use function DI\factory;
return [
Scheduler::class => factory(static function (ContainerInterface $container): Scheduler {
$scheduler = new Scheduler(
new TaskDispatcher(new FileLock(__DIR__ . '/../var/locks')),
new SystemClock(),
new DateTimeZone('Europe/Belgrade'),
);
$scheduler->invoke('daily-report', $container->get(App\Task\DailyReportTask::class))
->dailyAt('08:00')
->withoutOverlapping();
$scheduler->invoke('sync-inventory', $container->get(App\Task\SyncInventoryTask::class))
->everyNMinutes(5);
return $scheduler;
}),
// Tick's command classes — PHP-DI autowires the Scheduler constructor argument.
RunCommand::class => autowire(),
ListTasksCommand::class => autowire(),
RunTaskCommand::class => autowire(),
UpcomingCommand::class => autowire(),
];Both your Slim public/index.php and your bin/console build the same container. The console entry point pulls the three commands out of the container and registers them on a Symfony Application:
<?php
// bin/console
declare(strict_types=1);
use DI\ContainerBuilder;
use NemanjaJojic\Tick\Console\ListTasksCommand;
use NemanjaJojic\Tick\Console\RunCommand;
use NemanjaJojic\Tick\Console\RunTaskCommand;
use NemanjaJojic\Tick\Console\UpcomingCommand;
use Symfony\Component\Console\Application;
require __DIR__ . '/../vendor/autoload.php';
$containerBuilder = new ContainerBuilder();
$containerBuilder->addDefinitions(__DIR__ . '/../config/container.php');
$container = $containerBuilder->build();
$application = new Application('app', '1.0.0');
$application->add($container->get(RunCommand::class));
$application->add($container->get(ListTasksCommand::class));
$application->add($container->get(RunTaskCommand::class));
$application->add($container->get(UpcomingCommand::class));
$application->run();Invoke through your project's single console:
bin/console tick:run
bin/console tick:list
bin/console tick:run-task daily-report
bin/console tick:upcoming --within=1hThis pattern suits teams who already have a Slim HTTP app, want one DI container for both web and CLI, and don't want a separate scheduler entry point. Substitute Mezzio (same PHP-DI / Aura.Di approach) or Laminas (Laminas\Cli) with equivalent service definitions.
The five Console command classes Tick ships:
| Class | Name | Purpose |
|---|---|---|
RunCommand |
tick:run |
Run all due tasks (single tick). Cron-friendly. |
ListTasksCommand |
tick:list |
List registered task names, one per line. |
RunTaskCommand |
tick:run-task |
Force-run a single task by name. Honors lock and constraints. |
UpcomingCommand |
tick:upcoming |
Show upcoming task occurrences without executing anything. |
DetailCommand |
tick:detail |
Show full configuration for a single task plus the next N runs. |
For full usage, options, sample output, exit codes, and flock/systemd deployment recipes, see docs/cli.md.
The TaskBuilder returned by registration methods provides these configuration methods. Each method enforces a once-only contract: calling it twice throws InvalidScheduleException.
| Method | Cron Expression | Description |
|---|---|---|
cron(string $expression) |
Custom | Any valid cron expression |
everyNMinutes(int $minute) |
*/{minute} * * * * |
Every N minutes (1-59) |
hourly() |
0 * * * * |
At minute 0 of every hour |
hourlyAt(int $minute) |
{minute} * * * * |
At specified minute of every hour |
daily() |
0 0 * * * |
At midnight |
dailyAt(string $time) |
{minute} {hour} * * * |
At specified time (HH:MM format) |
weekly() |
0 0 * * 0 |
At midnight on Sunday |
weeklyOn(int $day, string $time) |
{minute} {hour} * * {day} |
At specified time on specified day (0=Sunday) |
monthly() |
0 0 1 * * |
At midnight on the 1st |
monthlyOn(int $day, string $time) |
{minute} {hour} {day} * * |
At specified time on specified day of month |
yearly() |
0 0 1 1 * |
At midnight on January 1st |
| Method | Description |
|---|---|
withoutOverlapping(int $ttlSeconds = 3600) |
Prevent concurrent execution via lock |
retry(RetryPolicyInterface $policy) |
Configure retry behavior on failure |
when(Closure $predicate) |
Run only if predicate returns true |
skip(Closure $predicate) |
Skip if predicate returns true |
describe(string $description) |
Attach a human-readable description (max 500 chars, surfaced by tick:detail) |
describe() attaches free-form documentation to a task. The description is stored on the RegisteredTask value object and surfaced by the tick:detail console command (both table and json formats). It does not affect scheduling or execution.
$scheduler->call('backup', static fn (): int => 0)
->daily()
->describe('Nightly database backup to S3.');Validation rules (enforced at build time, throw InvalidScheduleException):
- Cannot be empty or whitespace-only.
- Maximum 500 characters (measured with
mb_strlen, UTF-8 safe). - May not contain control characters; newlines (
\n,\r) and other ASCII control bytes are rejected to keep table rendering and log lines clean. Tabs (\t) are permitted. - May be called at most once per task.
Use withoutOverlapping() to prevent concurrent execution of the same task:
<?php
declare(strict_types=1);
$scheduler->invoke('long-running', $container->get(App\Task\LongRunningTask::class))
->everyNMinutes(1)
->withoutOverlapping(ttlSeconds: 3600);The lock key is the task name. The lock is held for the entire dispatch duration (including retry attempts and backoff sleeps). If the scheduler ticks again while a previous dispatch is still running, the subsequent tick observes LOCK_BUSY and is skipped.
The FileLock implementation uses kernel-level flock() with LOCK_EX | LOCK_NB for non-blocking acquisition. Locks are automatically released when the process exits.
For dev/local environments where overlap protection is undesirable, or for tests that exercise tasks declared with withoutOverlapping() without needing a writable filesystem, pass an inline no-op LockInterface implementation:
use NemanjaJojic\Tick\Lock\LockInterface;
use NemanjaJojic\Tick\Task\Dispatcher\TaskDispatcher;
$dispatcher = new TaskDispatcher(
lock: new class implements LockInterface {
public function acquire(string $key, int $ttlSeconds): bool
{
return true;
}
public function release(string $key): void
{
}
},
// ...
);Do NOT use a no-op lock in production multi-tick deployments — overlap protection is silently disabled.
Implement LockInterface for Redis, database, or other backends:
<?php
declare(strict_types=1);
namespace App\Lock;
use NemanjaJojic\Tick\Lock\LockInterface;
final class RedisLock implements LockInterface
{
public function __construct(
private readonly \Redis $redis,
) {
}
public function acquire(string $key, int $ttlSeconds): bool
{
return (bool) $this->redis->set(
'scheduler:lock:' . $key,
'1',
['NX', 'EX' => $ttlSeconds],
);
}
public function release(string $key): void
{
$this->redis->del('scheduler:lock:' . $key);
}
}Executes the task once. If it fails, the failure is recorded without retrying. This is the default if retry() is not called.
Retries with a constant delay between attempts:
<?php
declare(strict_types=1);
use NemanjaJojic\Tick\Retry\ConstantBackoffRetryPolicy;
$scheduler->invoke('my-task', $container->get(App\Task\MyTask::class))
->everyNMinutes(1)
->retry(new ConstantBackoffRetryPolicy(
maxAttempts: 3,
delayMs: 1000,
));Retries with linearly increasing delays, optionally capped. See AWS Architecture Blog: Exponential Backoff And Jitter for background on retry-delay strategies:
<?php
declare(strict_types=1);
use NemanjaJojic\Tick\Retry\LinearBackoffRetryPolicy;
$scheduler->invoke('my-task', $container->get(App\Task\MyTask::class))
->everyNMinutes(1)
->retry(new LinearBackoffRetryPolicy(
maxAttempts: 5,
baseDelayMs: 100,
incrementMs: 200,
maxDelayMs: 1000,
));AWS "full jitter" strategy. Spreads retries across the time window to avoid thundering-herd contention against a recovering downstream:
<?php
declare(strict_types=1);
use NemanjaJojic\Tick\Retry\JitteredExponentialBackoffRetryPolicy;
$scheduler->invoke('my-task', $container->get(App\Task\MyTask::class))
->everyNMinutes(1)
->retry(new JitteredExponentialBackoffRetryPolicy(
maxAttempts: 5,
baseDelayMs: 100,
maxDelayMs: 1000,
));AWS "decorrelated jitter" strategy. Produces good spread while avoiding the long-tail variance of full jitter. Recommended default for retry storms against shared infrastructure:
<?php
declare(strict_types=1);
use NemanjaJojic\Tick\Retry\DecorrelatedJitterRetryPolicy;
$scheduler->invoke('my-task', $container->get(App\Task\MyTask::class))
->everyNMinutes(1)
->retry(new DecorrelatedJitterRetryPolicy(
maxAttempts: 5,
baseDelayMs: 100,
maxDelayMs: 1000,
));Implement RetryPolicyInterface:
<?php
declare(strict_types=1);
namespace App\Retry;
use Closure;
use NemanjaJojic\Tick\Retry\RetryPolicyInterface;
use NemanjaJojic\Tick\Task\TaskResult;
final class CircuitBreakerRetryPolicy implements RetryPolicyInterface
{
public function execute(Closure $task): TaskResult
{
// Your custom retry logic
}
}Use when() and skip() to gate a due task behind a runtime predicate. The predicate is evaluated on every tick where the task's schedule matches.
<?php
declare(strict_types=1);
$scheduler->invoke('sync-reporting', $container->get(App\Task\SyncReportingTask::class))
->hourly()
->when(static fn (): bool => $flags->isEnabled('reporting.sync'));<?php
declare(strict_types=1);
$scheduler->invoke('heavy-aggregation', $container->get(App\Task\HeavyAggregationTask::class))
->everyNMinutes(5)
->skip(static function (): bool {
$hour = (int) (new \DateTimeImmutable('now', new \DateTimeZone('Europe/Belgrade')))->format('H');
return 2 === $hour;
});- Constraints fire before lock acquisition (a skipped tick does not consume the lock)
- Constraint evaluation is performed by the dispatcher; the scheduler delegates entirely
- If the predicate returns
false, the dispatcher returnsDispatchResult::skippedByConstraint()(logged atinfolevel when wrapped byLoggingTaskDispatcherDecorator) - If the predicate throws, the dispatcher returns
DispatchResult::skippedByConstraint($exception)(logged aterrorlevel when wrapped); the task is skipped - Each builder accepts exactly one constraint; calling
when()afterskip()(or either twice) throwsInvalidScheduleException
Tick has no built-in logger. To get structured PSR-3 logs of every dispatch outcome, wrap your dispatcher with LoggingTaskDispatcherDecorator:
<?php
declare(strict_types=1);
use NemanjaJojic\Tick\Lock\FileLock;
use NemanjaJojic\Tick\Task\Dispatcher\LoggingTaskDispatcherDecorator;
use NemanjaJojic\Tick\Task\Dispatcher\TaskDispatcher;
$inner = new TaskDispatcher(new FileLock('/var/run/scheduler'));
$dispatcher = new LoggingTaskDispatcherDecorator($inner, $logger);
$scheduler = new Scheduler($dispatcher, $clock, $timezone);The decorator emits one log entry per dispatch and catches any Throwable raised by the inner dispatcher (logged at critical), returning a synthetic DispatchResult::failed() so the loop continues.
Log levels:
| Outcome | Level | Message |
|---|---|---|
COMPLETED |
info |
Tick: task completed. |
FAILED |
error |
Tick: task failed. |
LOCK_BUSY |
info |
Tick: task skipped (lock not acquired). |
DRY_RUN |
info |
Tick: would run. |
SKIPPED_BY_CONSTRAINT (predicate returned false) |
info |
Tick: task skipped (constraint). |
SKIPPED_BY_CONSTRAINT (predicate threw) |
error |
Tick: constraint predicate failed. |
| inner dispatcher threw | critical |
Tick: dispatcher threw; task skipped. |
Without the decorator, the scheduler is silent. Dispatcher exceptions propagate.
The scheduler delegates execution to a TaskDispatcherInterface. Two implementations ship with the library:
TaskDispatcher— acquires lock, runs retry policy, executes taskDryRunTaskDispatcher— returnsDispatchResult::dryRun()without executing anything
Pick the dispatcher at the composition root:
<?php
declare(strict_types=1);
use NemanjaJojic\Tick\Lock\FileLock;
use NemanjaJojic\Tick\Task\Dispatcher\DryRunTaskDispatcher;
use NemanjaJojic\Tick\Task\Dispatcher\TaskDispatcher;
$isDryRun = 'true' === getenv('SCHEDULER_DRY_RUN');
$dispatcher = true === $isDryRun
? new DryRunTaskDispatcher()
: new TaskDispatcher(new FileLock('/var/run/scheduler'));In dry-run mode, constraint predicates are still evaluated (their side effects will fire). This is deliberate: dry-run reports what the real run would actually do given current runtime state.
The TaskDispatcherInterface allows decorator composition. The library ships TimingTaskDispatcherDecorator for execution timing:
<?php
declare(strict_types=1);
use NemanjaJojic\Tick\Lock\FileLock;
use NemanjaJojic\Tick\Task\Dispatcher\TaskDispatcher;
use NemanjaJojic\Tick\Task\Dispatcher\TimingTaskDispatcherDecorator;
$taskDispatcher = new TaskDispatcher(new FileLock('/var/run/scheduler'));
$timedDispatcher = new TimingTaskDispatcherDecorator($taskDispatcher);
$scheduler = new Scheduler($timedDispatcher, $clock, $timezone);The timing decorator adds timing.durationMs to the DispatchResult::$logContext, which the LoggingTaskDispatcherDecorator includes in log entries when both decorators are chained.
The Scheduler constructor takes a required DateTimeZone. Cron expressions are evaluated against wall-clock time in this timezone:
<?php
declare(strict_types=1);
use DateTimeZone;
// "09:00" runs at 09:00 Europe/Belgrade local time
$scheduler = new Scheduler(
$dispatcher,
new SystemClock(),
new DateTimeZone('Europe/Belgrade'),
);
$scheduler->invoke('daily-report', $container->get(App\Task\DailyReportTask::class))
->dailyAt('09:00');Instantiate multiple Scheduler instances for different timezone cohorts:
<?php
declare(strict_types=1);
$belgradeScheduler = new Scheduler($dispatcher, $clock, new DateTimeZone('Europe/Belgrade'));
$belgradeScheduler->invoke('belgrade-task', $container->get(App\Task\BelgradeTask::class))
->dailyAt('09:00');
$berlinScheduler = new Scheduler($dispatcher, $clock, new DateTimeZone('Europe/Berlin'));
$berlinScheduler->invoke('berlin-task', $container->get(App\Task\BerlinTask::class))
->dailyAt('09:00');
$belgradeScheduler->run();
$berlinScheduler->run();Run servers in UTC, keep ClockInterface in UTC, and let the Scheduler timezone be the only zoned component. This eliminates ambiguity in logs and audit trails.
The compose.yml provides a php service (PHP 8.2-cli-alpine).
# Install dependencies
HOST_USER_ID=$(id -u) docker compose run --rm php composer install
# Run tests
HOST_USER_ID=$(id -u) docker compose run --rm php composer test
# Run static analysis
HOST_USER_ID=$(id -u) docker compose run --rm php composer phpstan
# Run code style check
HOST_USER_ID=$(id -u) docker compose run --rm php composer cs
# Run mutation test
HOST_USER_ID=$(id -u) docker compose run --rm php composer infection
# Run full CI gate
HOST_USER_ID=$(id -u) docker compose run --rm php composer ciSee CONTRIBUTING.md for the contribution workflow, quality gates, and coding standards.
MIT License. See LICENSE for details.