Back to blog

Transpiling dragonmantank/cron-expression: timezones, DST, PHP date formats, and a cache for Intl.DateTimeFormat

dragonmantank/cron-expression is the library every PHP scheduler ends up depending on: Laravel's task scheduler, Symfony's scheduler bundle, every queue worker that has to ask "when does this next fire?". It is small, around 1.5k lines, with a single job: parse a cron expression, then compute the next or previous run time relative to a given DateTime. The whole library is essentially a stress test of the datetime module underneath it, which is exactly what made it a useful target.

The library is now at 100% on its test suite. None of the work was in the cron parser itself. All of it was underneath, in Pext's datetime module: timezones, DST transitions, the add / sub / diff family, the long tail of PHP-specific format characters, and a performance fix that turned a painfully slow run into a reasonable one.

Timezones and DST

Cron schedules look innocent until a daylight-saving boundary lands in the middle of one. "Every day at 02:30 in Europe/Berlin" has no answer on the spring-forward Sunday, because 02:30 does not exist that day; on the fall-back Sunday it has two answers, because 02:30 happens twice. PHP has a specific policy here, and the test suite encodes it precisely. The runtime had to match, not approximate.

The fix was a careful pass over how the datetime module represents and manipulates wall-clock times in a named timezone. Internally the runtime now keeps the UTC instant and the timezone separately, the way PHP does, rather than collapsing to a local string or to a JavaScript Date's host-timezone view. Field accessors (year, month, day, hour, minute, second) go through Intl.DateTimeFormat for the requested zone; arithmetic that crosses a DST boundary respects PHP's rule of "field-wise add in local time, then re-anchor". The result is that cron-expression's DST tests, which exercise both the missing hour and the doubled hour against several different zones, now pass without special-casing inside the library.

add, sub, diff and the rest of the arithmetic surface

cron-expression walks forward and backward from an anchor date in increments that range from one second to one month, and it calls back into the date object after every step. That means add, sub, modify, and diff all have to match PHP exactly, not approximately. Each one has corners.

add and sub with a DateInterval carry months and years through the calendar, not as a fixed number of days. Adding one month to 31 January is 28 February (or 29 in a leap year), not 3 March. diff returns a DateInterval whose y, m, d, h, i, s fields decompose the gap into calendar units with PHP's specific carry rules, and where the invert and days fields have to be populated correctly. modify('+1 day') goes through PHP's date parser and respects timezone wall-clock semantics, so adding one day across a DST boundary is not the same as adding 86400 seconds. DateTimeImmutable versions of all of these have to return new instances and leave the receiver untouched in every case, including the ones that mutate via reflection.

Each of these was tightened against the PHP reference. The cron suite exercises the combinations densely enough that drift in any one of them surfaces immediately.

PHP-specific date formats, in both directions

PHP's date() and DateTime::format() use a format-string language with 30+ single-character specifiers, several of which have no direct equivalent in JavaScript or in Intl.DateTimeFormat: S for the English ordinal suffix, L for the leap-year flag, N for ISO-8601 day-of-week with Monday as 1, W for the ISO-8601 week number with its specific 4-day rule, z for the zero-indexed day of year, t for the number of days in the current month, U for the Unix timestamp, plus the timezone forms e, O, P, p, T, and Z. The escape character \ has to be respected so that '\Y-m-d' renders as "Y-2026-05-14", not "2026-05-14".

The reverse direction is DateTime::createFromFormat and date_parse_from_format, which parse a string back into a date using the same alphabet plus a few parse-only specifiers (?, +, !, |, *, #). The parser has to handle separators, optional fields, the reset-to-defaults flag, and the carry-on-overflow behaviour for missing components. Then strtotime and the relative-format grammar ("first Monday of next month", "last day of February") sit on top of the same parser and must agree with PHP down to the boundary cases.

The bulk of this work was characterising each specifier against the PHP reference, then building the runtime so both directions go through a single shared table that owns the formatting and parsing rules. Cron exercises a moderate subset of the surface, but the work generalises: any library that reads or writes PHP-formatted date strings benefits.

Caching Intl.DateTimeFormat for a reasonable runtime

Once everything was correct, the suite was slow. The cron tests construct millions of intermediate dates across a year-long walk, and the datetime module was creating a fresh Intl.DateTimeFormat for each field read. Intl.DateTimeFormat is expensive to construct in Node.js: it loads ICU tables for the requested locale and timezone, sets up format patterns, and allocates non-trivial backing state. Constructing one for every hour or minute access is a measurable hit.

The fix is a small per-process cache keyed on the tuple of locale, timezone, and the field set being requested. The cache is bounded and uses a least-recently-used eviction so that long-running processes that touch many timezones do not balloon, but a typical workload (a handful of zones, a stable set of accessors) reuses the same formatters indefinitely. With the cache in place the cron suite drops from "noticeable wait" to "feels free", and unrelated date-heavy code benefits the same way.

This is the kind of optimisation that does not change any output, only the time it takes to produce it. The behaviour was already correct; the cache just stopped paying for the same construction on every access.

Where we are

dragonmantank/cron-expression is at 100%. The datetime module also went through a partial audit on the back of this work: every function exercised by cron, plus the surrounding ones in the same call paths, was cross-checked against the PHP reference, and the format-specifier table is now shared in both directions. A full audit of the module (covering the parts cron does not reach) is queued.

That makes five Pure Utilities packages at 100%: brick/math, composer/semver, doctrine/inflector, doctrine/lexer, and now cron-expression. See the full set on the open source showcase.