nemanjajojic / tick
Tick — a PHP task scheduler. One tick per cron minute. No daemon, no framework, no surprises.
Requires
- php: ^8.2
- dragonmantank/cron-expression: ^3.3
- psr/clock: ^1.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.27
- mockery/mockery: ^1.6
- phpstan/phpstan: ^1.11
- phpstan/phpstan-mockery: ^1.1
- phpunit/phpunit: ^10.5
- psr/log: ^3.0
- symfony/console: ^7.0
Suggests
- psr/log: Required to use LoggingTaskDispatcherDecorator for observability. Install with: composer require psr/log
- symfony/console: Required to use the optional Console command classes (RunCommand, ListTasksCommand, RunTaskCommand). Install with: composer require symfony/console
README
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.
Architecture
For an architectural overview of Tick's components and their runtime interactions, see docs/architecture.md.
Design Decisions
- 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,NullLock),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.
Boundary: What the Library Ships vs. What You Provide
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
Installation
Core (library only)
composer require nemanjajojic/tick
This 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.).
With the optional Console commands
composer require nemanjajojic/tick symfony/console
This 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.
Requirements
- PHP 8.2+
- Composer 2
Quick Start
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();
Usage Patterns
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.
Pattern 1: Pure library (no CLI, no Symfony)
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>&1
That 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
Pattern 2: Standalone Symfony CLI with PHP-DI
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-scheduler
Crontab entry:
* * * * * cd /var/www/myapp && php bin/my-scheduler tick:run >> var/log/scheduler.log 2>&1
This 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
Pattern 3: Framework command bus (Slim + PHP-DI)
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=1h
This 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.
CLI Reference
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.
Configuration Reference
The TaskBuilder returned by registration methods provides these configuration methods. Each method enforces a once-only contract: calling it twice throws InvalidScheduleException.
Schedule Methods
| 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 |
Behavior Methods
| 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) |
Task Descriptions
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.
Overlap Protection (Locking)
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.
NullLock — Disabling Overlap Protection
NemanjaJojic\Tick\Lock\NullLock is a no-op LockInterface implementation: acquire() always returns true and release() does nothing. Useful for tests that exercise tasks declared with withoutOverlapping() without needing a writable filesystem, and for dev/local environments where overlap protection is undesirable.
use NemanjaJojic\Tick\Lock\NullLock; use NemanjaJojic\Tick\Task\Dispatcher\TaskDispatcher; $dispatcher = new TaskDispatcher( lock: new NullLock(), // ... );
Do NOT use NullLock in production multi-tick deployments — overlap protection is silently disabled.
Custom Lock Implementations
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); } }
Retry Policies
NoRetryPolicy (Default)
Executes the task once. If it fails, the failure is recorded without retrying. This is the default if retry() is not called.
ConstantBackoffRetryPolicy
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, ));
LinearBackoffRetryPolicy
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, ));
JitteredExponentialBackoffRetryPolicy
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, ));
DecorrelatedJitterRetryPolicy
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, ));
Custom Retry Policy
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 } }
Constraints (when / skip)
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.
when() — Feature Flags and Environment Gating
<?php declare(strict_types=1); $scheduler->invoke('sync-reporting', $container->get(App\Task\SyncReportingTask::class)) ->hourly() ->when(static fn (): bool => $flags->isEnabled('reporting.sync'));
skip() — Maintenance Windows
<?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; });
Predicate Contract
- 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
Observability
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.
Dry Run
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.
Composing Dispatchers
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.
Timezones
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');
Multiple Timezones
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();
Recommendation
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.
Development with Docker
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 ci
Contributing
See CONTRIBUTING.md for the contribution workflow, quality gates, and coding standards.
License
MIT License. See LICENSE for details.