ez-php / scheduler
Cron-based job scheduler with mutex-backed overlap prevention for ez-php applications
Requires
- php: ^8.5
- ez-php/console: ^2.0
- ez-php/contracts: ^2.0
- ez-php/support: ^2.0
Requires (Dev)
- ez-php/docker: ^2.0
- ez-php/testing-application: ^2.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
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 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-09-30 19:50:25 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
Bind a configured Scheduler in a service provider's register():
use EzPhp\Scheduler\Mutex\FileMutex; use EzPhp\Scheduler\Scheduler; $this->app->bind(Scheduler::class, function (): Scheduler { $scheduler = new Scheduler(new FileMutex(sys_get_temp_dir() . '/ez-schedule-locks')); $scheduler->command('queue:work --max-jobs=100')->everyMinute()->withoutOverlapping(); $scheduler->command('cache:prune')->hourly(); $scheduler->command('reports:generate')->daily(); return $scheduler; });
Register the scheduler:run command before bootstrap (e.g. in public/index.php and ez):
$app->registerCommand(\EzPhp\Scheduler\Console\SchedulerRunCommand::class);
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.
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), */N (every
N steps starting from 0). Ranges (1-5) and lists (1,3,5) are not supported —
compose several command() calls, or use one of the predefined frequency
methods, if you need those. 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.
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.
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 |
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 |
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).
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).