Tick — a PHP task scheduler. One tick per cron minute. No daemon, no framework, no surprises.

Maintainers

Package info

github.com/nemanjajojic/tick

pkg:composer/nemanjajojic/tick

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.3.0 2026-05-17 09:23 UTC

This package is auto-updated.

Last update: 2026-05-17 09:26:50 UTC


README

PHP 8.2+ License: MIT PHPStan: max PHPUnit: 374 tests Code Coverage: 98% Infection MSI: 90%

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), and TaskDispatcherInterface (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 throws InvalidScheduleException. 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:

  • Scheduler class with three explicit registration methods: call(), invoke(), raw()
  • Two built-in task adapters (CallableTask, RawTask) plus the open TaskInterface contract
  • Cron expression parsing (via dragonmantank/cron-expression)
  • File-based locking (FileLock) with flock() 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 Scheduler and registers tasks
  • Console wiring — if you want the CLI commands, you instantiate Symfony\Component\Console\Application and 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 TaskInterface objects)
  • Crontab entry to invoke the scheduler every minute
  • Custom LockInterface if 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.

  1. Pure library — call $scheduler->run() from your own PHP entry point. No symfony/console, no command classes.
  2. Standalone Symfony Console application — install symfony/console, add the three Tick command classes to your own Application, invoke via cron.
  3. 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:list and tick:run-task <name> for ops and debugging
  • Multi-environment setups (bin/my-scheduler-staging, bin/my-scheduler-prod)
  • Projects already invested in symfony/console for 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 returns DispatchResult::skippedByConstraint() (logged at info level when wrapped by LoggingTaskDispatcherDecorator)
  • If the predicate throws, the dispatcher returns DispatchResult::skippedByConstraint($exception) (logged at error level when wrapped); the task is skipped
  • Each builder accepts exactly one constraint; calling when() after skip() (or either twice) throws InvalidScheduleException

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 task
  • DryRunTaskDispatcher — returns DispatchResult::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.