Skip to content

Build a cron expression a field at a time - #14

Open
KalimeroMK wants to merge 2 commits into
masterfrom
fluent-cron-builder
Open

KalimeroMK wants to merge 2 commits into
masterfrom
fluent-cron-builder

Conversation

@KalimeroMK

Copy link
Copy Markdown
Contributor

A cron expression says when a task runs in five positional fields, which is terse to write and slow to read. CronBuilder sets them one at a time, opened by named constructors on RecurringTask:

RecurringTask::daily()->at('05:00')->run('report:daily');
RecurringTask::everyFiveMinutes()->between('08:00', '17:00')->run('cache:warm');
RecurringTask::weekly()->on('monday')->at('09:30')->run('report:weekly');
RecurringTask::monthly()->onLastDayOfMonth()->at('23:00')->run('invoice:issue');

The chain starts with how often the task runs — everyMinute(), everyMinutes(), everyFiveMinutes(), everyTenMinutes(), everyFifteenMinutes(), everyThirtyMinutes(), hourly(), everyHours(), daily(), weekly(), monthly(), quarterly(), yearly() — and narrows it with at(), atHours(), atMinutes(), on(), weekdays(), weekends(), onDays(), onLastDayOfMonth(), inMonths(), between(), unlessBetween() and timezone(). It ends with run() for a task, toTrigger() for a trigger alone, or getExpression() for the expression as a string.

Every step returns a new builder, so a half-built expression can be kept and finished in more than one way. A task built this way is the one the same expression passed to RecurringTask::cron() gives, identifier included, so an existing schedule can be rewritten into the builder without a task being treated as new.

A time window is compiled into the expression rather than checked when the task is about to run, so schedule:list and the next run date of the trigger account for it. That is what limits between() to whole hours: a cron field cannot say "from 08:30".

Steps that guard each other

A builder whose steps overwrite one another silently is worse than the raw expression it replaces, so each step refuses to change how often the step before it runs:

  • between() and unlessBetween() narrow the hours the expression already runs at rather than replacing them. everyHours(6)->between('08:00', '17:00') keeps the one run of the four falling in the window instead of becoming an hourly task, and a window leaving nothing of the expression is refused rather than given hours nobody asked for.
  • at() replaces both the hour and the minute, so it needs a chain that already runs once a day. On hourly() or everyFiveMinutes() it would drop the repetition without saying so, and is refused in favour of atMinutes() and atHours().
  • A day of the week and a day of the month cannot both be set, because cron runs a task on a day matching either field once both are narrowed: weekly()->onLastDayOfMonth() would run on every Monday as well as on the last day of the month. An expression passed to the constructor can still say that.
  • An interval has to divide its unit evenly — everyMinutes() takes 1, 2, 3, 4, 5, 6, 10, 12, 15, 20 or 30 and everyHours() takes 1, 2, 3, 4, 6, 8 or 12 — so that the pair of runs either side of an hour or a day is an interval apart like every other pair.

A timezone is checked where it is given rather than where the trigger is built, so a typo reports at the step that introduced it and as this package's own exception.

Notes

  • Nothing in the runtime changes: the builder only produces a CronExpressionTrigger, so it does not touch the ground Draft: publish command/task object model #12 covers.
  • weekly() runs on Mondays, the day every('1 week') runs on as well.
  • A known limitation this does not address: a task identifier is derived from the trigger's string form, which omits the timezone, so two tasks differing only by timezone() still need withId() to share a schedule. That is the behaviour documented under "Task identifiers" today; changing it would move the identifier of every cron task with an explicit timezone, so it belongs in its own pull request.

A cron expression says when a task runs in five positional fields, which is
terse to write and slow to read. `CronBuilder` sets them one at a time, opened
by named constructors on `RecurringTask`:

    RecurringTask::daily()->at('05:00')->run('report:daily');
    RecurringTask::everyFiveMinutes()->between('08:00', '17:00')->run('cache:warm');
    RecurringTask::weekly()->on('monday')->at('09:30')->run('report:weekly');

The chain ends with `run()` for a task, `toTrigger()` for a trigger alone, or
`getExpression()` for the expression as a string. Every step returns a new
builder, so a half-built expression can be finished in more than one way. A
task built this way is the one the same expression passed to
`RecurringTask::cron()` gives, identifier included, so an existing schedule can
be rewritten into the builder without a task being treated as new.

A time window is compiled into the expression rather than checked when the task
is about to run, so `schedule:list` and the next run date of the trigger account
for it. That is what limits `between()` to whole hours: a cron field cannot say
"from 08:30".

Each step guards the frequency the step before it set, so that no chain quietly
runs more or less often than both of its calls asked for:

- `between()` and `unlessBetween()` narrow the hours the expression already runs
  at rather than replacing them, so `everyHours(6)->between('08:00', '17:00')`
  keeps the one run of the four falling in the window instead of becoming an
  hourly task. A window leaving nothing of the expression is refused.
- `at()` replaces both the hour and the minute, so it needs a chain that already
  runs once a day; on `hourly()` or `everyFiveMinutes()` it would drop the
  repetition, and is refused in favour of `atMinutes()` and `atHours()`.
- A day of the week and a day of the month cannot both be set, because cron runs
  a task on a day matching either field once both are narrowed:
  `weekly()->onLastDayOfMonth()` would run on every Monday as well as on the
  last day of the month. An expression passed to the constructor can still say
  that.
- An interval has to divide its unit evenly, so that the pair of runs either
  side of an hour or a day is an interval apart like every other pair.

A timezone is checked where it is given rather than where the trigger is built,
so a typo reports at the step that introduced it and as this package's own
exception.
The lock file is not committed, so CI resolves the development dependencies
afresh: phpstan/phpstan 2.3.4 bundles phpstan/phpdoc-parser 2.4.0 in its phar
while rector/rector 2.8.2 bundles 2.3, both unprefixed. The parser from one
copy then builds nodes for the class of the other, and every file fails with
CallableTypeNode::__construct() being given a FullyQualifiedNameNode.
@KalimeroMK
KalimeroMK requested review from a team and samdark October 29, 2026 00:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant