Skip to content

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 →