Every few months I end up staring at a crontab line wondering "is this actually going to fire when I think it will?" The usual answer is to open a browser tab, paste the expression into one of a dozen online calculators, and hope the one I picked is the one that agrees with the cron my production box actually runs.
phpcron is the tiny CLI I wish I had kept in /usr/local/bin the whole time. It is a ~400-line PHP 8.2 program with three commands:
-
next <expr>— print the next N matching timestamps -
explain <expr>— describe the schedule in plain English -
match <expr> <datetime>— exit0if that instant matches,1if not
No Composer runtime dependencies, ships as a single-stage Alpine Docker image, and (most importantly) the match command is designed to slot into shell pipelines as an exit-code oracle.
This is post #190 in SEN's 100+ portfolio sweep. This entry is about the unglamorous kind of tool: one you spend half a day writing and then quietly use for years.
Why build it at all?
There is no shortage of cron parsers on Packagist. dragonmantank/cron-expression is the usual pick and it is genuinely good. If you are writing a framework-scale scheduler you should use it.
But this tool is for me, at the terminal, trying to answer a handful of extremely specific questions:
- "When exactly does this fire?"
- "Does this weird expression I copied from a Jenkinsfile actually do what I think?"
- "Write me a one-liner in my deploy script that only runs during business hours."
For (1) I want next to print real UTC timestamps. For (2) I want explain to read like a sentence. For (3) I want match to return a clean exit code. Everything else — timezone math, @reboot, seconds-granularity cron, year fields, nicknames — is out of scope. YAGNI.
The parser
The parser is a pure function. Give it a string, get back an immutable CronExpression object with five integer arrays:
final class CronExpression
{
public function __construct(
public readonly string $raw,
public readonly array $minutes, // 0-59
public readonly array $hours, // 0-23
public readonly array $dom, // 1-31
public readonly array $months, // 1-12
public readonly array $dow, // 0-6 (Sun=0)
) {}
}
Each array holds the sorted, unique list of allowed values for that field. That means the rest of the code never has to re-parse anything — matching becomes in_array($minute, $expr->minutes, true) and so on. Memory is negligible (the biggest field, minutes, is 60 ints = half a kilobyte) and the "does this time match?" check is a handful of in_array calls.
The token grammar is four cases:
| Token | Meaning |
|---|---|
* |
every value |
N |
literal |
N-M |
inclusive range |
*/S |
every S from field minimum |
N-M/S |
every S within a range |
A,B,C |
list (any mix of the above) |
The parser does one pass per field, splitting on , first, then handling / for steps, then - for ranges. The whole thing lives in src/Parser.php:
private function parseToken(string $token, int $min, int $max, int $index): array
{
$step = 1;
$rangePart = $token;
if (str_contains($token, '/')) {
[$rangePart, $stepStr] = explode('/', $token, 2);
if (!ctype_digit($stepStr) || (int) $stepStr < 1) {
throw new \InvalidArgumentException("bad step: $token");
}
$step = (int) $stepStr;
}
if ($rangePart === '*') {
[$start, $end] = [$min, $max];
} elseif (str_contains($rangePart, '-')) {
[$startS, $endS] = explode('-', $rangePart, 2);
$start = $this->toInt($startS, $index);
$end = $this->toInt($endS, $index);
if ($start > $end) {
throw new \InvalidArgumentException("range start > end: $token");
}
} else {
$start = $end = $this->toInt($rangePart, $index);
}
if ($start < $min || $end > $max) {
throw new \InvalidArgumentException("out of range: $token");
}
$out = [];
for ($v = $start; $v <= $end; $v += $step) {
$out[] = $v;
}
return $out;
}
One small but important detail: day-of-week accepts 7 as a synonym for Sunday. That is a Vixie-cron quirk — the POSIX cron used to allow 0 for Sunday and 7 for Sunday, and a lot of existing crontabs rely on 0-7/2 working "correctly". The parser normalizes 7 to 0 right at the toInt step, before any range logic runs, so downstream code never has to think about it.
The "either day" quirk
The matcher is mostly boring — "is $minute in the minutes array?" five times — except for one historical landmine: when both day-of-month and day-of-week are restricted, Vixie cron triggers if either field matches, not both. This is why 0 0 1 * 1 ("midnight on the 1st of the month OR any Monday") does the thing most people expect.
phpcron implements this rule explicitly:
$domRestricted = !$this->isFullRange($expr->dom, 1, 31);
$dowRestricted = !$this->isFullRange($expr->dow, 0, 6);
if ($domRestricted && $dowRestricted) {
return $domMatch || $dowMatch;
}
return $domMatch && $dowMatch;
A test covers the edge case so I don't break it next week:
public function testDomOrDowSemantics(): void
{
$e = $this->parser->parse('0 0 1 * 1'); // midnight on day-1 OR Monday
$this->assertTrue($this->matcher->matches($e, $this->dt('2026-04-06 00:00'))); // Mon
$this->assertTrue($this->matcher->matches($e, $this->dt('2026-04-01 00:00'))); // day 1
$this->assertFalse($this->matcher->matches($e, $this->dt('2026-04-02 00:00'))); // neither
}
If you have ever been burned by a cron entry that "mostly worked" for years and then surprised everyone on a month boundary, it was probably this rule.
Iterating forward without regret
NextRun is the fun one. Given a CronExpression and a DateTimeImmutable, produce the next N matches.
Naive approach: start at from + 1 minute and step one minute at a time, calling matches() each iteration. For * * * * * that's fine. For 0 0 29 2 * ("midnight on Feb 29") it is spectacularly slow — we'd scan four years' worth of minutes (≈ 2 million iterations) for a single answer.
The fix is to fast-forward coarse fields whenever they cannot possibly match:
while (count($results) < $count) {
$month = (int) $cursor->format('n');
if (!in_array($month, $expr->months, true)) {
$cursor = $cursor
->modify('first day of next month')
->setTime(0, 0, 0);
continue;
}
if (!$this->dayMatches($expr, $dom, $dow)) {
$cursor = $cursor->modify('+1 day')->setTime(0, 0, 0);
continue;
}
$hour = (int) $cursor->format('G');
if (!in_array($hour, $expr->hours, true)) {
$cursor = $cursor->setTime($hour, 0, 0)->modify('+1 hour');
continue;
}
if ($this->matcher->matches($expr, $cursor)) {
$results[] = $cursor;
}
$cursor = $cursor->modify('+1 minute');
}
The wrong month? Skip to the start of the next candidate month. Wrong day? Skip to tomorrow at 00:00. Wrong hour? Skip to the next hour at :00. This collapses the Feb 29 case to roughly "check four months, twenty-eight/twenty-nine days, one hour, one minute" per result.
A 4-year hard cap protects against truly unsatisfiable expressions — for example, 0 0 30 2 * ("Feb 30th") which the parser will accept but which can never fire. The loop throws a RuntimeException with a useful message instead of spinning forever. I consider "fail loudly" the correct answer here; if someone genuinely needs to test Feb-30-only inputs, they have bigger problems.
A subtle point: the loop advances the cursor by 1 minute before the first iteration, so "next" is always strictly in the future. If you call next with --from "2026-04-16 00:00" on a schedule that matches midnight, you get 2026-04-17 00:00, not 2026-04-16 00:00. This matches the semantics of every other "next N runs" tool I've used, and saves a lot of off-by-one confusion in shell scripts.
The CLI layer
Cli.php is the only file that knows about argv, STDIN/STDOUT, or exit codes. It is written to be testable — it takes the argv array and two writable stream resources as parameters instead of reading from $argv and STDOUT globally:
public function run(array $argv, mixed $stdout, mixed $stderr): int
That lets the test suite pipe a fake argv in and capture both streams with php://memory:
private function invoke(array $argv): array
{
$stdout = fopen('php://memory', 'w+');
$stderr = fopen('php://memory', 'w+');
$exit = (new Cli())->run(array_merge(['phpcron'], $argv), $stdout, $stderr);
rewind($stdout);
rewind($stderr);
return [$exit, stream_get_contents($stdout), stream_get_contents($stderr)];
}
The match-or-not tests then become trivial:
public function testMatchExitsZeroWhenMatches(): void
{
[$exit, $out] = $this->invoke(['match', '0 9 * * 1', '2026-04-13 09:00']);
$this->assertSame(Cli::EXIT_OK, $exit);
$this->assertStringContainsString('match', $out);
}
Exit-code discipline: 0 on success (or "match" returning true), 1 on runtime errors including "no match", 2 on usage errors. This matches what test(1) and friends do, and lets you write if phpcron match ... in bash without wrapping in conditional noise.
Shipping it
bin/phpcron is a ~15-line shim with a hand-rolled PSR-4 autoloader:
#!/usr/bin/env php
<?php
declare(strict_types=1);
spl_autoload_register(static function (string $class): void {
$prefix = 'Sen\\Phpcron\\';
if (!str_starts_with($class, $prefix)) return;
$rel = substr($class, strlen($prefix));
$path = __DIR__ . '/../src/' . str_replace('\\', '/', $rel) . '.php';
if (is_file($path)) require $path;
});
use Sen\Phpcron\Cli;
exit((new Cli())->run($argv, STDOUT, STDERR));
No Composer required at runtime. composer.json only pulls in PHPUnit under require-dev. The Dockerfile is single-stage on php:8.2-cli-alpine, copies src/ and bin/ into /app, drops to a non-root user, and sets ENTRYPOINT:
FROM php:8.2-cli-alpine
WORKDIR /app
COPY src/ ./src/
COPY bin/ ./bin/
RUN chmod +x /app/bin/phpcron && adduser -D -u 10001 phpcron
USER phpcron
ENTRYPOINT ["/app/bin/phpcron"]
Because there is no vendor/, the image stays small and there are no runtime dependency CVEs to worry about. The only attack surface is PHP itself, which I am fine trusting the base image to keep current.
What I'd do differently
A few honest trade-offs:
UTC-only is a real limitation. Real cron runs in the host's local timezone and has to deal with DST transitions (what happens to 30 2 * * * on a spring-forward night?). I sidestepped this by declaring phpcron UTC-only and documenting it. For my use case — sanity-checking expressions, not actually scheduling jobs — that's fine.
No support for @reboot, @daily, and friends. Trivial to add, but I never use them. I'd rather the tool reject them loudly than accept them and guess what the user meant.
The explain output is functional, not pretty. It produces sentences like "At every 15 minutes at hours 9, 10, 11, 12, 13, 14, 15, 16, 17, on Monday, Tuesday, Wednesday, Thursday, Friday." A human-friendly version would collapse contiguous ranges ("9am to 5pm on weekdays") but that's a surprising amount of code for limited gain. If this tool ever gets a v2, that's where the work would go.
41 tests, and they all run in 7 milliseconds. Parser (16), Matcher (6), NextRun (6), Explainer (6), CLI (7). That's the full safety net for the whole project. Every code path I care about has at least one test; I added the testAtMidnightStrictlyFuture test specifically because I had an off-by-one in an earlier draft.
Try it
git clone https://github.com/masaru87/phpcron.git
cd phpcron
./bin/phpcron explain "*/15 9-17 * * 1-5"
Or just run it in Docker:
docker build -t phpcron .
docker run --rm phpcron next "0 */4 * * *" --count 6
The source is small enough to read end-to-end in one sitting — which is, honestly, the kind of tool I enjoy most.
A note on testing strategy
One thing worth calling out: the tests are split into five files that mirror the source layout exactly. ParserTest only tests the parser. MatcherTest only tests matching (with a parser alongside for convenience, since the matcher takes a CronExpression, not a string). NextRunTest exercises iteration behavior. ExplainerTest checks the sentence generator. CliTest tests the glue.
I used to try to write "integration tests" that spun up subprocesses and parsed stdout. They are slow, flaky, and when they fail the error messages are terrible. Taking writable streams as explicit parameters to Cli::run() means the CLI tests are just in-process function calls with captured output — fast, deterministic, and the failures are readable. It is a small design change with outsized payoff, and I now do it in every CLI I write regardless of language.
The other habit worth adopting: every test file exists to answer "what could silently break?" not "what code am I covering?" Coverage metrics are fine, but they tempt you to write tests that exercise lines without actually pinning down behavior. The testDomOrDowSemantics test is a good example — it exists because that exact behavior is a historical landmine, not because it was the cheapest way to hit a line. When I refactor the matcher in six months, that test will catch me if I "simplify" the rule by accident. That is the job of a test suite.

Top comments (0)