workerman/crontab
workerman/crontab is a lightweight cron scheduler for Workerman PHP applications. It adds second-level cron expressions and recurring task scheduling to Workerman workers, letting you run background jobs on timers without relying on external cron.
composer require workerman/crontab (pulls workerman/workerman). PHP 7.4+ CLI SAPI required — this is a long-running process, not a request/response library. On PHP 8.4, require ^1.0.7 — earlier tags throw runtime errors on 8.4 (fixed in v1.0.7, PR #17); pin "workerman/crontab": "^1.0.7" if your fleet is on 8.4, otherwise ^1.0 is fine.README.md for the expression grammar, then src/Crontab.php (where onWorkerStart registers the internal tick timer and callbacks fire) and src/Parser.php (the actual field grammar — this is where you'll go when a rule silently never fires).*/5 * * * * * = every 5 seconds; 0 * * * * * = every minute at second 0.<?php
require __DIR__ . '/vendor/autoload.php';
use Workerman\Worker;
use Workerman\Crontab\Crontab;
date_default_timezone_set('UTC');
$worker = new Worker(); // no listener needed
$worker->name = 'cron';
$worker->count = 1;
$worker->onWorkerStart = function () {
new Crontab('*/5 * * * * *', function () {
echo date('Y-m-d H:i:s') . " heartbeat\n";
});
};
Worker::runAll();
php cron.php start. Add -d only for production. The first realistic use case is a heartbeat/pruning job (e.g. delete expired sessions, flush temp tables), not a replacement for a real job queue.count = 1. This is the pattern to default to. All crontabs register inside onWorkerStart, so reload re-registers them cleanly:$worker = new Worker();
$worker->count = 1;
$worker->onWorkerStart = function () {
new Crontab('0 * * * * *', [Jobs\TickMinute::class, 'run']);
new Crontab('0 0 * * * *', [Jobs\TickHour::class, 'run']);
new Crontab('0 0 3 * * *', [Jobs\TickNightly::class, 'run']);
new Crontab('0 0 0 1 * *', [Jobs\TickMonthly::class, 'run']);
};
Crontab instance, so use use for injected services (container, logger, config) rather than globals:new Crontab('*/10 * * * * *', function (Crontab $cron) use ($container) {
$container->make(ReportJob::class)->handle();
}, [], 'reports'); // 4th arg sets an id you can remove later
false to have the scheduler remove the entry:new Crontab('*/1 * * * * *', function () use ($deadline) {
if (time() >= $deadline) {
return false; // destroys this crontab
}
doDeferredWork();
});
Also $cron->destroy() and (by id) Crontab::remove('reports') for external cancellation.
Isolate blocking jobs. The tick loop is single-threaded per process. If job A takes 30s but fires every 5s, job B on the same worker gets starved. Either put each long job on its own Worker (count = 1, own crontab), or have the crontab only enqueue to Redis/Beanstalk and let real workers consume. The latter is nearly always the right answer.
Laravel integration — do not replace schedule:run with this. Run it as a Supervisor-managed Artisan command that dispatches the same jobs:
class CronWorkerCommand extends Command
{
protected $signature = 'cron:work {action=start}';
public function handle()
{
$kernel = $this->laravel;
$worker = new Worker();
$worker->name = 'cron';
$worker->count = 1;
$worker->onWorkerStart = function () use ($kernel) {
new Crontab('* * * * * *', function () use ($kernel) {
$kernel->call('schedule:run');
});
};
Worker::$pidFile = storage_path('app/cron.pid');
Worker::$logFile = storage_path('logs/cron.log');
Worker::$stdoutFile = storage_path('logs/cron.out');
Worker::runAll();
}
}
Observability. Keep a heartbeat crontab that writes a "last run" timestamp to Redis/file, and log start, duration, id, result around every callback. A stale heartbeat is your only early warning that the worker died.
Rollout/rollback. Use php start.php reload (re-runs onWorkerStart, re-registers crontabs) rather than restart — restart drops in-flight jobs mid-callback. Under Supervisor, prefer stopwaitsecs long enough to let the current tick finish.
Pin and upgrade deliberately. The upgrade path is a composer update workerman/crontab + php start.php restart — reload won't pick up new vendor code if you're on an opcache-less long-running process (it does, since PHP re-includes on onWorkerStart, but restart is the safe habit for library bumps). v1.0.6 → v1.0.7 is a patch-only change (PHP 8.4 runtime error fix); no API, grammar, or behavior changes, so no code edits are expected.
/etc/crontab or Laravel's schedule will produce a wrong schedule or a parse error. @daily-style macros aren't supported by the parser either.new Crontab(...) outside Worker::runAll() simply never fires. There is no facade, no service provider, no automatic registration.count > 1 duplicates every job. Each worker process registers its own Crontab and fires independently — two processes = two executions per tick. Keep scheduler workers at count = 1 unless duplication is what you actually want.*/5 * * * * * job will delay everything else.SET NX PX, filesystem flock, DB advisory lock).Worker::$stdoutFile when things look silent.date_default_timezone_set() at the top of the entry script before Worker::runAll(). A UTC default in production silently shifts every hourly/daily rule.*, */n, n, n-m, n,m,..., and step variants. Day-of-week: 0 and 7 both mean Sunday. Validate every rule in a unit test — a bad expression may throw from Parser only at registration time inside onWorkerStart, which is easy to miss in daemon mode.php start.php reload from a system-level cron or a nightly posix_kill) if leaks are hard to eliminate.false silently destroys. Great for one-shots, terrible when left in accidentally — the job runs once and then nothing. Pair it with an explicit condition and a log line.composer show workerman/crontab before debugging expressions or timezones. Conversely, if you're on PHP 7.4/8.0–8.3, v1.0.6 and v1.0.7 are functionally identical, so there's no urgency to bump.echo/Worker::log(). php start.php status shows worker state; php start.php connections is irrelevant here. Per-job duration logging is the fastest way to find a slow job starving the loop.Crontab and override checkRule (or wrap Parser) to add @daily/@every 5m macros, per-rule metrics, or feature flags. Crontab::getAll() enumerates registered jobs from inside the same process if you want a health endpoint.MAILTO, no env vars, no retries, no durable missed-run catch-up — if the process is down at tick time, the run is gone. For at-least-once, persisted, retryable behavior, use a queue; let this package only enqueue.How can I help you explore Laravel packages today?