Cron Expression Explainer — PHP source
Translate any 5-field cron expression into plain English, build one field-by-field, and preview the next time it will fire. Supports steps, ranges, lists, and named days/months. Runs 100% in your browser.
This is the PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.
<?php
/**
* Pure 5-field cron parser, natural-language explainer, builder, and next-run
* calculator — PHP polyglot port.
*
* Language: PHP
* CosmoDev polyglot showcase port of the "cron-explainer" tool.
* Ported from src/lib/cron-explainer.ts — display source, part of CosmoDev's
* polyglot tool pages.
*
* Zero deps (stdlib only). Deterministic. Times are interpreted as UTC so
* results are unambiguous and DST-independent (the caller controls the
* instant). DateTimeImmutable's overflow-normalising setters mirror the
* JavaScript Date semantics used in the reference implementation.
*
* The public surface mirrors the TypeScript reference: explain_cron,
* build_cron, next_run.
*/
declare(strict_types=1);
namespace CosmoDev\CronExplainer;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use InvalidArgumentException;
// ─── Field model ───────────────────────────────────────────────────────────
//
// The five cron fields in positional order, each with its numeric range and
// whether it accepts named tokens (JAN..DEC / SUN..SAT). `wrap_max` is true
// only for day-of-week, where 7 is an alias for 0 (Sunday). A small value
// object keeps the per-field metadata together and readable.
final class FieldMeta
{
public function __construct(
public string $name, // 'minute'|'hour'|'day-of-month'|'month'|'day-of-week'
public string $label, // surfaced in error messages (same as $name)
public int $min,
public int $max,
public bool $named, // accepts JAN..DEC / SUN..SAT tokens
public bool $wrap_max, // max value wraps to min (dow: 7 → 0)
) {}
}
/** @var FieldMeta[] */
const FIELDS = [
// Built up via field_meta() below so the constructor stays explicit.
];
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
const DOW_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
// Insertion order is preserved by PHP arrays; mirrors the TS object iteration
// order used during token substitution.
const MONTH_TOKENS = [
'JAN' => 1, 'FEB' => 2, 'MAR' => 3, 'APR' => 4, 'MAY' => 5, 'JUN' => 6,
'JUL' => 7, 'AUG' => 8, 'SEP' => 9, 'OCT' => 10, 'NOV' => 11, 'DEC' => 12,
];
const DOW_TOKENS = [
'SUN' => 0, 'MON' => 1, 'TUE' => 2, 'WED' => 3, 'THU' => 4, 'FRI' => 5, 'SAT' => 6,
];
/**
* The five cron fields in positional order. Declared as a function so the
* constructor reads explicitly at first use rather than as a global literal.
*
* @return FieldMeta[]
*/
function field_metas(): array
{
return [
new FieldMeta('minute', 'minute', 0, 59, false, false),
new FieldMeta('hour', 'hour', 0, 23, false, false),
new FieldMeta('day-of-month', 'day-of-month', 1, 31, false, false),
new FieldMeta('month', 'month', 1, 12, true, false),
new FieldMeta('day-of-week', 'day-of-week', 0, 7, true, true),
];
}
function pad2(int $n): string
{
return str_pad((string) $n, 2, '0', STR_PAD_LEFT);
}
function month_name(int $m): string
{
return MONTH_NAMES[$m - 1];
}
function dow_name(int $d): string
{
return DOW_NAMES[$d % 7];
}
/** Inclusive integer range, e.g. mk_range(1, 5) -> [1, 2, 3, 4, 5]. */
function mk_range(int $lo, int $hi): array
{
return $lo <= $hi ? range($lo, $hi) : [];
}
/**
* Parse a strictly-numeric token (digits only). Rejects named tokens, signs,
* and surrounding garbage so malformed fields surface as a clear error.
*/
function parse_int_strict(string $s, string $label): int
{
$t = trim($s);
if ($t === '' || !ctype_digit($t)) {
throw new InvalidArgumentException("{$label}: invalid number \"{$s}\"");
}
return (int) $t;
}
/**
* Replace named tokens (JAN..DEC / SUN..SAT) with their numeric values.
* Uses global substring replacement so ranges like "JUN-AUG" and lists like
* "MON,WED,FRI" normalize in a single pass over the field.
*/
function normalize_field(string $value, FieldMeta $meta): string
{
$v = strtoupper(trim($value));
if (!$meta->named) {
return $v;
}
$tokens = $meta->name === 'month' ? MONTH_TOKENS : DOW_TOKENS;
foreach ($tokens as $tok => $num) {
$v = str_replace($tok, (string) $num, $v);
}
return $v;
}
/**
* A field after expansion: the matched values plus the raw token and a flag
* distinguishing a bare `*` (wildcard) from an explicit enumeration.
*/
final class ParsedField
{
/**
* @param int[] $values Deduped, sorted numbers this field matches.
*/
public function __construct(
public FieldMeta $meta,
public string $raw,
public array $values,
public bool $wildcard,
) {}
}
/**
* Expand one field value into the explicit set of numbers it matches.
*
* Handles the wildcard star, a stepped wildcard (star-slash-N), explicit
* ranges (A-B), stepped ranges (A-B/N), a single value (A), a value with a
* step that runs to the field max (A/N), and comma-separated lists of any of
* these. Returns the deduped, sorted values plus a wildcard flag that
* distinguishes a bare star from an enumeration.
*/
function expand_field(string $value, FieldMeta $meta): ParsedField
{
$norm = normalize_field($value, $meta);
if ($norm === '') {
throw new InvalidArgumentException("{$meta->label}: empty field");
}
if ($norm === '*') {
return new ParsedField($meta, $value, mk_range($meta->min, $meta->max), true);
}
$out = []; // value => true, used as a dedup set.
foreach (explode(',', $norm) as $term) {
if ($term === '') {
throw new InvalidArgumentException("{$meta->label}: empty list item");
}
$slashPos = strpos($term, '/');
$base = $term;
$step = 1;
if ($slashPos !== false) {
$base = substr($term, 0, $slashPos);
$step = parse_int_strict(substr($term, $slashPos + 1), $meta->label);
if ($step <= 0) {
throw new InvalidArgumentException("{$meta->label}: step must be a positive number");
}
}
if ($base === '*') {
$lo = $meta->min;
$hi = $meta->max;
} elseif (str_contains($base, '-')) {
$dash = strpos($base, '-');
$lo = parse_int_strict(substr($base, 0, $dash), $meta->label);
$hi = parse_int_strict(substr($base, $dash + 1), $meta->label);
} else {
$lo = parse_int_strict($base, $meta->label);
// "A/step" runs from A to the field max; a bare "A" is a single value.
$hi = $slashPos !== false ? $meta->max : $lo;
}
if ($lo > $hi) {
throw new InvalidArgumentException("{$meta->label}: range start {$lo} is greater than end {$hi}");
}
if ($lo < $meta->min) {
throw new InvalidArgumentException("{$meta->label}: value {$lo} is below minimum {$meta->min}");
}
if ($hi > $meta->max) {
throw new InvalidArgumentException("{$meta->label}: value {$hi} is above maximum {$meta->max}");
}
for ($v = $lo; $v <= $hi; $v += $step) {
$resolved = ($meta->wrap_max && $v === $meta->max) ? $meta->min : $v;
$out[$resolved] = true;
}
}
$values = array_keys($out);
sort($values, SORT_NUMERIC);
return new ParsedField($meta, $value, $values, false);
}
/**
* Parse all five fields. Returns [ParsedField[], null] on success or
* [null, string error] on failure.
*
* @return array{0: ?array, 1: ?string}
*/
function parse_expr(string $expr): array
{
$tokens = preg_split('/\s+/', trim($expr)) ?: [];
$tokens = array_values(array_filter($tokens, fn ($t) => $t !== ''));
if (count($tokens) !== 5) {
return [null, 'Expected 5 fields (minute hour day-of-month month day-of-week), got ' . count($tokens)];
}
$metas = field_metas();
$parts = [];
for ($i = 0; $i < 5; $i++) {
try {
$parts[] = expand_field($tokens[$i], $metas[$i]);
} catch (InvalidArgumentException $e) {
return [null, $e->getMessage()];
} catch (\Throwable $e) {
return [null, "Invalid {$metas[$i]->name} field"];
}
}
return [$parts, null];
}
/** True when a sorted value list is a contiguous run (e.g. [3, 4, 5, 6]). */
function is_contiguous(array $values): bool
{
for ($i = 1, $n = count($values); $i < $n; $i++) {
if ($values[$i] - $values[$i - 1] !== 1) {
return false;
}
}
return true;
}
/** Describe a single value in the field's own vocabulary. */
function single_value(int $n, FieldMeta $meta): string
{
return match ($meta->name) {
'minute' => "minute {$n}",
'hour' => "hour {$n}",
'day-of-month' => "day {$n} of the month",
'month' => month_name($n),
'day-of-week' => dow_name($n),
default => (string) $n,
};
}
/**
* Describe a parsed field as a human phrase (no leading preposition).
* `raw` is consulted to distinguish step syntax (star/N or A-B/N) from plain
* lists, since two different raw forms can expand to the same value set.
*/
function describe_field(ParsedField $p): string
{
$meta = $p->meta;
$raw = $p->raw;
$values = $p->values;
if ($p->wildcard) {
return match ($meta->name) {
'minute' => 'every minute',
'hour' => 'every hour',
'day-of-month' => 'every day of the month',
'month' => 'every month',
'day-of-week' => 'every day of the week',
default => '',
};
}
// Step syntax is reported as "every N <units>".
if (str_contains($raw, '/') && count($values) >= 1) {
$slashPos = strpos($raw, '/');
$step = parse_int_strict(substr($raw, $slashPos + 1), $meta->label);
$start = $values[0];
$unitPlural = match ($meta->name) {
'day-of-month' => 'days of the month',
'day-of-week' => 'days of the week',
default => "{$meta->name}s",
};
if ($start === $meta->min) {
return "every {$step} {$unitPlural}";
}
return "every {$step} {$unitPlural} starting at " . single_value($start, $meta);
}
if (count($values) === 1) {
return single_value($values[0], $meta);
}
if (is_contiguous($values)) {
$a = $values[0];
$b = $values[count($values) - 1];
if ($meta->name === 'month') {
return month_name($a) . ' through ' . month_name($b);
}
if ($meta->name === 'day-of-week') {
return dow_name($a) . ' through ' . dow_name($b);
}
$unitPlural = $meta->name === 'day-of-month' ? 'days' : "{$meta->name}s";
return "{$unitPlural} {$a} through {$b}";
}
// Explicit list of discrete values.
if ($meta->name === 'month') {
return implode(', ', array_map('month_name', $values));
}
if ($meta->name === 'day-of-week') {
return implode(', ', array_map('dow_name', $values));
}
if ($meta->name === 'minute') {
return 'minutes ' . implode(', ', $values);
}
if ($meta->name === 'hour') {
return 'hours ' . implode(', ', $values);
}
return 'days ' . implode(', ', $values) . ' of the month';
}
/**
* Prepend a preposition, but never before a phrase that already leads with
* "every" (e.g. "every day of the week" reads wrong as "on every …").
*/
function prepend_word(string $prefix, string $phrase): string
{
return str_starts_with($phrase, 'every') ? $phrase : "{$prefix} {$phrase}";
}
/** Compose the opening time-of-day clause from the minute and hour fields. */
function time_clause(ParsedField $minute, ParsedField $hour): string
{
$mAll = $minute->wildcard;
$hAll = $hour->wildcard;
$mSingle = !$mAll && count($minute->values) === 1;
$hSingle = !$hAll && count($hour->values) === 1;
if ($mAll && $hAll) {
return 'Every minute';
}
if ($mAll && $hSingle) {
return 'Every minute of hour ' . $hour->values[0];
}
if ($mSingle && $hAll) {
return 'At minute ' . $minute->values[0] . ' of every hour';
}
if ($mSingle && $hSingle) {
return 'At ' . pad2($hour->values[0]) . ':' . pad2($minute->values[0]);
}
// Mixed: describe each non-wildcard field, hour first.
$clauses = [];
if (!$hAll) {
$clauses[] = describe_field($hour);
}
if (!$mAll) {
$clauses[] = describe_field($minute);
}
$s = implode(', ', $clauses);
return ucfirst($s);
}
function compose_description(array $parts): string
{
[$minute, $hour, $dom, $month, $dow] = $parts;
$clauses = [time_clause($minute, $hour)];
if (!$dom->wildcard) {
$clauses[] = prepend_word('on', describe_field($dom));
}
if (!$month->wildcard) {
$clauses[] = prepend_word('in', describe_field($month));
}
if (!$dow->wildcard) {
$clauses[] = prepend_word('on', describe_field($dow));
}
return implode(', ', $clauses);
}
// ─── Public API ────────────────────────────────────────────────────────────
final class CronFieldInfo
{
public function __construct(
public string $field, // one of the five positional field names
public string $value, // raw field value as written in the expression
public string $meaning, // human-readable description of what it matches
) {}
}
final class CronExplanation
{
/**
* @param CronFieldInfo[] $fields One entry per field; empty when invalid.
*/
public function __construct(
public bool $valid,
public string $description, // '' when invalid
public array $fields = [],
public ?string $error = null, // present only when valid is false
) {}
}
/**
* Parse and explain a 5-field cron expression in plain English.
*
* example: explain_cron('30 14 * * *')->description === 'At 14:30'
*/
function explain_cron(string $expr): CronExplanation
{
[$parts, $error] = parse_expr($expr);
if ($error !== null) {
return new CronExplanation(false, '', [], $error);
}
$fields = array_map(
fn (ParsedField $p) => new CronFieldInfo($p->meta->name, $p->raw, describe_field($p)),
$parts,
);
return new CronExplanation(true, compose_description($parts), $fields, null);
}
/**
* Assemble a 5-field cron expression from per-field specs. Each field defaults
* to `*` when empty/omitted; invalid fields throw InvalidArgumentException so
* callers cannot build a malformed expression.
*
* example: build_cron(['minute' => '30', 'hour' => '14']) === '30 14 * * *'
*
* @param array{minute?:string,hour?:string,dom?:string,month?:string,dow?:string} $opts
*/
function build_cron(array $opts = []): string
{
$metas = field_metas();
$keys = ['minute', 'hour', 'dom', 'month', 'dow'];
$out = [];
foreach ($keys as $i => $key) {
$value = isset($opts[$key]) ? (string) $opts[$key] : '';
$v = trim($value);
if ($v === '') {
$out[] = '*';
continue;
}
expand_field($v, $metas[$i]); // validates; throws on bad input
$out[] = $v;
}
return implode(' ', $out);
}
/**
* Next time the expression fires, strictly after `after`, evaluated in UTC.
*
* Implements standard Vixie-cron day matching: when BOTH day-of-month and
* day-of-week are restricted, a match on either suffices (OR); otherwise both
* must match (AND). Returns null if no firing occurs within ~3 years.
*
* `after` is interpreted in UTC; DateTimeImmutable's normalising setters make
* the field advances behave exactly like JavaScript's setUTC* overflow rules.
*/
function next_run(string $expr, DateTimeInterface $after): ?DateTimeImmutable
{
[$parts, $error] = parse_expr($expr);
if ($error !== null) {
return null;
}
[$minute, $hour, $dom, $month, $dow] = $parts;
// Sets for O(1) membership tests.
$mSet = array_flip($minute->values);
$hSet = array_flip($hour->values);
$domSet = array_flip($dom->values);
$monSet = array_flip($month->values);
$dowSet = array_flip($dow->values);
$domWild = $dom->wildcard;
$dowWild = $dow->wildcard;
$utc = new DateTimeZone('UTC');
$cur = (new DateTimeImmutable('now', $utc))
->setDate((int) $after->format('Y'), (int) $after->format('n'), (int) $after->format('j'))
->setTime((int) $after->format('G'), (int) $after->format('i'), 0);
// Start at the top of the minute following `after` (UTC), seconds zeroed.
$cur = $cur->modify('+1 minute');
$limit = (int) $cur->format('Y') + 3; // hard stop ~3 years out
while ((int) $cur->format('Y') < $limit) {
if (!isset($monSet[(int) $cur->format('n')])) {
// setUTCMonth(+1, 1) + zero time → first day of next month.
$cur = $cur->modify('first day of next month')->setTime(0, 0, 0);
continue;
}
$domOk = isset($domSet[(int) $cur->format('j')]);
$dowOk = isset($dowSet[(int) $cur->format('w')]); // 0 (Sun) .. 6 (Sat)
$dayOk = ($domWild || $dowWild) ? ($domOk && $dowOk) : ($domOk || $dowOk);
if (!$dayOk) {
$cur = $cur->modify('+1 day')->setTime(0, 0, 0);
continue;
}
if (!isset($hSet[(int) $cur->format('G')])) {
// setUTCHours(+1, 0, 0): setTime normalises hour overflow into day.
$cur = $cur->setTime((int) $cur->format('G') + 1, 0, 0);
continue;
}
if (!isset($mSet[(int) $cur->format('i')])) {
// setUTCMinutes(+1, 0, 0): minute overflow normalises into hour.
$cur = $cur->setTime((int) $cur->format('G'), (int) $cur->format('i') + 1, 0);
continue;
}
return $cur;
}
return null;
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →