ez-php / scheduler
Cron-based job scheduler with mutex-backed overlap prevention for ez-php applications
Requires
- php: ^8.5
- ez-php/console: ^2.7.0
- ez-php/contracts: ^2.7.0
- ez-php/support: ^2.7.0
Requires (Dev)
- ez-php/cache: ^2.7.0
- ez-php/docker: ^2.7.0
- ez-php/testing-application: ^2.7.0
- friendsofphp/php-cs-fixer: ^3.94
- phpstan/phpstan: ^2.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^13.0
Suggests
- ext-redis: Needed for Mutex\RedisMutex
- ez-php/cache: Needed for Mutex\CacheMutex (overlap prevention on any ez-php/cache lock: Redis, Memcached, Database, File)
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 2.7.1
- 2.7.0
- 2.6.5
- 2.6.4
- 2.6.3
- 2.6.2
- 2.6.1
- 2.6.0
- 2.5.10
- 2.5.9
- 2.5.8
- 2.5.7
- 2.5.6
- 2.5.5
- 2.5.4
- 2.5.3
- 2.5.2
- 2.5.1
- 2.5.0
- 2.4.11
- 2.4.10
- 2.4.9
- 2.4.8
- 2.4.7
- 2.4.6
- 2.4.5
- 2.4.4
- 2.4.3
- 2.4.2
- 2.4.1
- 2.4.0
- 2.3.9
- 2.3.8
- 2.3.7
- 2.3.6
- 2.3.5
- 2.3.4
- 2.3.3
- 2.3.2
- 2.3.1
- 2.3.0
- 2.2.1
- 2.2.0
- 2.1.1
- 2.1.0
- 2.0.1
- 2.0.0
- 1.14.0
- 1.13.1
- 1.13.0
- 1.12.2
- 1.12.1
- 1.12.0
- 1.11.2
- 1.11.1
- 1.11.0
- 1.10.0
- 1.9.2
- 1.9.1
- 1.9.0
- 1.8.0
- 1.7.1
- 1.7.0
- 1.6.1
- 1.6.0
- 1.5.1
- 1.5.0
- 1.4.2
- 1.4.1
- 1.4.0
- 1.3.0
- 1.2.0
- 1.1.1
- 1.1.0
- 1.0.1
- 1.0.0
- 0.9.3
- 0.9.2
- 0.9.1
This package is auto-updated.
Last update: 2026-10-30 15:49:16 UTC
README
Cron-based job scheduler for ez-php applications. Register commands with a fluent frequency API, prevent overlapping runs via pluggable mutex drivers (File, Database), and execute due jobs from a single cron entry.
Installation
composer require ez-php/scheduler
Quick Start
Register SchedulerServiceProvider (in an ez-php application: uncomment it in
provider/modules.php). It binds one shared Scheduler with the mutex chosen by
config/scheduler.php and registers scheduler:run and scheduler:list:
// config/scheduler.php return [ 'mutex' => 'file', // file | database | database_expiry | redis | cache | none 'ttl' => 3600, // database_expiry, redis, cache 'file' => ['path' => sys_get_temp_dir() . '/ez-scheduler-locks'], 'redis' => ['host' => '127.0.0.1', 'port' => 6379, 'database' => 0], ];
database/database_expiry use the bound DatabaseInterface's PDO, cache the bound
ez-php/cache CacheInterface. Entries stay application code — add them in the
boot() of a provider registered after SchedulerServiceProvider:
use EzPhp\Scheduler\Scheduler; public function boot(): void { $scheduler = $this->app->make(Scheduler::class); // shared instance $scheduler->command('queue:work --max-jobs=100')->everyMinute()->withoutOverlapping(); $scheduler->command('cache:prune')->hourly(); $scheduler->command('reports:generate')->daily(); }
Without the provider, bind a Scheduler yourself and register the commands before
bootstrap ($app->registerCommand(SchedulerRunCommand::class), likewise
SchedulerListCommand).
Run it from a cron entry, once per minute:
* * * * * php /var/www/html/ez scheduler:run
scheduler:run runs every due entry through the application's console — the entry
string is split on whitespace, so 'queue:work --max-jobs=100' calls queue:work with
--max-jobs=100 — honouring withoutOverlapping() and the optional logger. It stops at
the first entry that fails (non-zero exit or exception) and exits 1.
It is deliberately named scheduler:run: the framework's own schedule:run drives the
framework's simpler EzPhp\Console\Schedule\Scheduler (no overlap prevention, no
cron()), not this package's. Use one or the other, not both from cron.
scheduler:list prints every entry with its frequency, overlap setting, the next due
minute (searched up to 31 days ahead) and the lock state. The lock is read without
being acquired, so listing never makes a running scheduler:run skip an entry; this
needs a mutex implementing InspectableMutexInterface (RedisMutex, DatabaseMutex,
DatabaseMutexWithExpiry) — FileMutex and CacheMutex show unknown.
Outside an ez-php application, call run() with your own executor:
$scheduler->run(new DateTimeImmutable(), static function (string $command): void { // dispatch $command however your application runs commands; throw on failure });
Frequency Methods
All methods are fluent and return ScheduleEntry for chaining:
| Method | When due |
|---|---|
everyMinute() |
Every cron invocation |
everyFiveMinutes() |
When minute % 5 === 0 |
hourly() |
At :00 of every hour |
daily() |
At 00:00 |
weekly() |
On Sunday at 00:00 |
monthly() |
On the 1st of the month at 00:00 |
cron(string $expression) |
Matches a five-field cron expression (minute hour dom month dow) |
An entry without a frequency set is never due.
Explicit cron expressions
For schedules the predefined helpers don't cover, pass a standard five-field expression directly:
$scheduler->command('reports:weekly')->cron('30 6 * * 1'); // 06:30 every Monday $scheduler->command('sync:external')->cron('*/15 * * * *'); // every 15 minutes
Supported field syntax per position: * (any), N (exact value), A-B
(inclusive range), */N (every N steps starting from 0), A-B/N (every N steps
from A up to B) and comma-separated lists of those (1,15,30, 1-5,10).
Day-of-week counts Sunday as 0 or 7, as standard cron does (0 9 * * 5-7 = Fri–Sun):
$scheduler->command('reports:hourly')->cron('0 8-18/2 * * 1-5'); // every 2 hours 08–18, Mon–Fri
When both day-of-month and day-of-week are restricted, either one matching is enough (standard cron): 0 9 1,15 * 1-5 runs on the 1st, the 15th and every weekday. If either field starts with *, both must match.
Names (MON), macros (@daily) and L/W/# are not supported. A malformed expression (not exactly five
space-separated fields) is simply never due, same as an entry with no
frequency set at all.
Reconciling with ez-php/queue's own scheduler
ez-php/queue ships an independent job-class-based scheduler
(Scheduling\Scheduler + Scheduling\ScheduledTask, driven by the
queue:schedule console command) with its own cron-expression matching but
no overlap prevention — nothing stops two overlapping queue:schedule
cron ticks from both matching the same due task and double-pushing the same
job, if a tick ever runs long enough to still be executing when the next
one starts.
The two packages are not merged — ez-php/queue's job-class model
(ScheduledTask::createJob()) and this package's console-command model
(ScheduleEntry::getCommand()) are different enough that unifying them
would be a real merge, which is explicitly out of scope. Instead, register
queue:schedule itself as a single withoutOverlapping() entry here, so
this package's mutex protects the entire due-job-pushing step as one atomic
unit, regardless of how many individual ScheduledTasks it evaluates
internally:
$scheduler->command('queue:schedule')->everyMinute()->withoutOverlapping();
Then point your system cron at this package's scheduler:run only —
remove any separate * * * * * ez queue:schedule cron line, since this
entry now invokes it (mutex-guarded) on your behalf:
* * * * * php /var/www/html/ez scheduler:run
This is pure integration glue: queue:schedule's own cron-matching and job
dispatch logic (ez-php/queue's Scheduling\Scheduler::dueJobs()) is
unchanged and still runs exactly as before — this package's mutex now simply
wraps the single point where it used to be invoked directly by cron, closing
the "queue:schedule takes over a minute, cron overlaps it" race window
without either package depending on the other.
Overlap Prevention
Call withoutOverlapping() to skip a command if a previous invocation is still running:
$scheduler->command('queue:work')->everyMinute()->withoutOverlapping();
Requires a MutexInterface passed to the Scheduler constructor. A SchedulerException is thrown at runtime if withoutOverlapping() is used without a mutex configured.
Mutex Drivers
FileMutex
Uses PHP's flock(LOCK_EX|LOCK_NB) on per-command lock files in a configurable directory.
use EzPhp\Scheduler\Mutex\FileMutex; $mutex = new FileMutex('/var/run/ez-php/locks'); $scheduler = new Scheduler($mutex);
- The lock directory is created automatically if it does not exist.
- Lock files are never deleted — their inodes remain stable across runs.
- The lock is tied to the file handle, so a crashed process releases it automatically on the next cron run.
- Suitable for single-server deployments.
DatabaseMutex
Uses a scheduler_locks table (created automatically via CREATE TABLE IF NOT EXISTS). Acquiring a lock inserts a row; releasing it deletes the row. A duplicate-key violation signals the lock is already held.
use EzPhp\Scheduler\Mutex\DatabaseMutex; $mutex = new DatabaseMutex($pdo); // any PDO instance $scheduler = new Scheduler($mutex);
- Compatible with MySQL and SQLite.
- No automatic TTL/expiry — stale rows from crashed processes must be cleaned manually. Use
DatabaseMutexWithExpiryif that matters. - Suitable for multi-server deployments sharing the same database.
DatabaseMutexWithExpiry
Same idea as DatabaseMutex, but each lock carries an expiry timestamp. If the
process holding a lock dies without releasing it, the next acquire() reclaims
the key once the expiry has passed — the schedule recovers on its own instead of
blocking until someone clears the row by hand.
use EzPhp\Scheduler\Mutex\DatabaseMutexWithExpiry; $mutex = new DatabaseMutexWithExpiry($pdo, 600); // TTL in seconds (default 3600) $scheduler = new Scheduler($mutex);
- Uses its own
scheduler_locks_ttltable, so it coexists withDatabaseMutex. (CREATE TABLE IF NOT EXISTSnever adds a column to an existing table, so the expiry column could not be retrofitted ontoscheduler_lockssafely.) - Pick a TTL comfortably longer than the command's worst-case runtime. A TTL shorter than the actual runtime lets another process reclaim the lock while the first is still working, which defeats overlap prevention entirely.
- Expired rows are reclaimed lazily, per key, on the next acquire. There is no background sweep — keys that stop being scheduled keep their last row.
- Each lock row carries a random owner token:
release()deletes it only while it still holds this instance's token, so a run that overruns its TTL can't remove a lock another process has reclaimed since. A table created by an older version gets itsownercolumn added automatically.
RedisMutex
For scheduled commands running on more than one host. FileMutex is bound to a
single filesystem and DatabaseMutex to a single database; Redis is usually
already shared across application servers.
use EzPhp\Scheduler\Mutex\RedisMutex; $redis = new Redis(); $redis->connect('127.0.0.1', 6379); $mutex = new RedisMutex($redis, 600); // TTL in seconds (default 3600) $scheduler = new Scheduler($mutex);
- Uses a single atomic
SET key value NX EX ttl, so there is no read-then-write race between concurrent cron processes. - The value is a random owner token:
release()deletes the key only while it still holds this instance's token (Lua compare-and-delete), so a run that overruns its TTL can't remove a lock another process has taken since. - Locks carry a TTL, so a crashed process does not block the schedule forever. Pick a TTL longer than the command's worst-case runtime.
- Keys are namespaced with
ez-php:scheduler:lock:so they cannot collide with application data in a shared Redis database. - Fails closed: if Redis is unreachable,
acquire()returns false and the run is skipped, because the lock cannot be proven free. Running a scheduled job twice is the outcome this class exists to prevent. - Requires
ext-redis; the constructor throwsRuntimeExceptionwithout it.
CacheMutex
Overlap prevention on the locks of an ez-php/cache store the application already
runs — Redis, Memcached, Database (owner-token locks shared across hosts) or File.
Requires ez-php/cache (composer require ez-php/cache).
use EzPhp\Scheduler\Mutex\CacheMutex; $mutex = new CacheMutex($app->make(\EzPhp\Cache\CacheInterface::class), 600); // TTL (default 3600) $scheduler = new Scheduler($mutex);
release()goes through the lock objectacquire()obtained, so it never deletes a lock another process has taken after this run's TTL expired.- Keys are namespaced with
ez-php:scheduler:lock:. - Don't use
ArrayDriveroutside tests — its locks are per process.
API Reference
Scheduler
new Scheduler(?MutexInterface $mutex = null)
| Method | Description |
|---|---|
command(string $name): ScheduleEntry |
Register a command and return its entry for chaining |
all(): list<ScheduleEntry> |
Return all registered entries |
dueEntries(DateTimeInterface $time): list<ScheduleEntry> |
Return entries whose predicate matches $time |
run(DateTimeInterface $time, callable $executor): void |
Execute all due entries via the callable |
isLocked(ScheduleEntry $entry): ?bool |
Read-only lock state; null without withoutOverlapping() or an InspectableMutexInterface mutex |
ScheduleEntry
| Method | Description |
|---|---|
everyMinute(): self |
Due on every invocation |
everyFiveMinutes(): self |
Due at minute :00, :05, :10, … |
hourly(): self |
Due at minute :00 |
daily(): self |
Due at 00:00 |
weekly(): self |
Due on Sunday at 00:00 |
monthly(): self |
Due on the 1st at 00:00 |
cron(string $expression): self |
Due when the five-field cron expression matches |
withoutOverlapping(bool $enabled = true): self |
Enable mutex-based skip |
isDue(DateTimeInterface $time): bool |
Evaluate the frequency predicate |
getCommand(): string |
Return the registered command name |
getMutexKey(): string |
Return a stable sha1-derived lock key |
nextRunAfter(DateTimeInterface $after, int $maxMinutes = 44640): ?DateTimeImmutable |
Next due minute within the window (default 31 days), or null |
MutexInterface
interface MutexInterface { public function acquire(string $key): bool; public function release(string $key): void; }
Implement this interface to add custom mutex backends (e.g. Redis, Memcached).
Implement InspectableMutexInterface (adds isLocked(string $key): bool) as well when
the backend can report a held lock without acquiring it — scheduler:list then shows
the lock state.
Custom Mutex
use EzPhp\Scheduler\MutexInterface; final class MemcachedMutex implements MutexInterface { /** @var array<string, string> owner token per acquired key */ private array $tokens = []; public function __construct(private readonly \Memcached $memcached) {} public function acquire(string $key): bool { $token = bin2hex(random_bytes(16)); if (!$this->memcached->add($key, $token, 300)) { return false; } $this->tokens[$key] = $token; return true; } public function release(string $key): void { $token = $this->tokens[$key] ?? null; unset($this->tokens[$key]); $item = $this->memcached->get($key, null, \Memcached::GET_EXTENDED); // Only delete the lock if it is still ours — after a TTL overrun another // process may hold it. cas() with a negative expiry expires it atomically. if ($token !== null && is_array($item) && $item['value'] === $token) { $this->memcached->cas($item['cas'], $key, $token, -1); } } }
Store an owner token and release only while the lock still carries it — a plain delete lets a run that overran its TTL remove a lock another process has taken since.
Exceptions
SchedulerException (extends RuntimeException) is thrown when:
withoutOverlapping()is used but noMutexInterfacewas passed toSchedulerFileMutexcannot create the lock directory or open a lock file
Exceptions from the executor callable propagate up after the mutex lock is released (guaranteed via finally).