Skip to content

Improving Cron expression DX to reduce cron expression generation errors and typo - #217

Open
nyamsprod wants to merge 2 commits into
dragonmantank:masterfrom
nyamsprod:feature/cron-expression-builder
Open

Improving Cron expression DX to reduce cron expression generation errors and typo#217
nyamsprod wants to merge 2 commits into
dragonmantank:masterfrom
nyamsprod:feature/cron-expression-builder

Conversation

@nyamsprod

@nyamsprod nyamsprod commented Jul 5, 2026

Copy link
Copy Markdown

Cron Expression Building

Introduction

Currently, to create a CronExpression we must use the CronExpression constructor
and provide a valid cron expression string. On error an exception is thrown.
The issue is that even if we can find online some CRON syntax validator and even
if the syntax is well documented in the package README file, making typo while
building a new CronExpression is one of the most common mistake when dealing
with Cron expressions.

use Cron\CronExpression;

$cronExpression = new CronExpression('MON * * * *');
// is this where the `MON` should have been ?
// is this a valid Cron expression.

Proposal

In order to provide a more ergonomic and efficient solution for CRON expression
building, a fluent API is added to the current CronExpression class to improve
CRON expression building.

echo CronExpression::daily()
    ->everyDaysOfWeek(5)
    ->listHours([8, 12, 15])
    ->everyInRangeDaysOfMonth(start: 12, end: 30, step:3)
    ->getExpression()
;

// returns "0 8,12,15 12-30/3 * */5"

Added Methods

The complete new methods signature to be added are the following:

namespace Cron;

class CronExpression
{
    /**
     * Creates a cron schedule that runs every minute.
     * This is the equivalent to `new CronExpression('* * * * *')`
     */
    public static function minutely(
        FieldFactoryInterface $fieldFactory = new FieldFactory()
    ): self;

    /**
     * Creates a cron schedule that runs every day at a specific time.
     *
     * @throws InvalidArgumentException
     */
    public static function dailyAt(
        string|int $hour,
        string|int $minute,
        ?FieldFactoryInterface $fieldFactory = null, 
    ): self;

    /**
     * Creates a cron schedule that runs weekly on a specific weekday and time.
     *
     * @throws InvalidArgumentException
     */
    public static function weeklyOn(
        string|int $dayOfWeek,
        string|int $hour,
        string|int $minute,
        ?FieldFactoryInterface $fieldFactory = null,
    ): self;

    /**
     * Creates a cron schedule that runs monthly on a specific day and time.
     *
     * @throws InvalidArgumentException
     */
    public static function monthlyOn(
        string|int $dayOfMonth,
        string|int $hour,
        string|int $minute,
        ?FieldFactoryInterface $fieldFactory = null,
    ): self;

    /**
     * Creates a cron schedule that runs yearly on a specific month and day.
     *
     * Hour and minute default to midnight.
     *
     * @throws InvalidArgumentException
     */
    public static function yearlyOn(
        string|int $month,
        string|int $dayOfMonth,
        string|int $hour = 0,
        string|int $minute = 0,
        ?FieldFactoryInterface $fieldFactory = null,
    ): self;

    /**
     * Creates a cron schedule based on CronExpression registered aliases
     *
     * @see CronExpression::getAliases()
     *
     * The `@` is omitted when calling the method.
     * The FieldFactory instance can be passed as the only
     * argument of the alias method call named `$fieldFactory`
     * 
     * ie: CronExpression::daily(?FieldFactoryInterface $fieldFactory = null); 
     *
     * @throws InvalidArgumentException
     */
    public static function __callStatic(string $name, array $arguments = []): self;

    /**
     * Sets a field to run every N units using cron step syntax
     *
     * @throws InvalidArgumentException
     */
    public function every(int $step, int $position): self;

    /**
     * Sets a field to a range of values using cron range syntax
     *
     * @throws InvalidArgumentException
     */
    public function range(string|int $start, string|int $end, int $position): self;
    
    /**
     * Sets a field to run every N units within a range.
     *
     * @throws InvalidArgumentException
     */
    public function everyInRange(string|int $start, string|int $end, int $step, int $position): self;

    /**
     * Sets a field to a list of values using cron list syntax
     *
     * @param iterable<string|int> $list
     *
     * @throws InvalidArgumentException
     */
    public function list(iterable $list, int $position): self;
    
    /**
     * @throws InvalidArgumentException
     * @throws BadMethodCallException
     */
    public function __call(string $name, array $arguments): mixed;
}

Design consideration

Constructors

Named constructors

CronExpression provides named constructors to create common cron expressions through explicit, type-safe entry points:

  • minutely(): self
  • dailyAt(string|int $hour, string|int $minute): self
  • weeklyOn(string|int $dayOfWeek, string|int $hour, string|int $minute): self
  • monthlyOn(string|int $dayOfMonth, string|int $hour, string|int $minute): self
  • yearlyOn(string|int $month, string|int $dayOfMonth, string|int $hour = 0, string|int $minute = 0): self

These methods provide a safer and more predictable alternative to directly instantiating the class by making the intent explicit at the call site.

Like the constructor, each named constructor accepts an optional FieldFactoryInterface implementation as its final argument. When omitted, the default field factory implementation is used.

__callStatic() to access aliases

The __callStatic() magic method provides a convenient way to create expressions from registered cron aliases using an expressive API.

For example:

$expression = CronExpression::daily();

$expression->getExpression();
// returns the equivalent of:
// new CronExpression('@daily')

Because daily() maps to the registered @daily cron alias, it provides a more discoverable alternative to passing the alias string directly.

Like the named constructors, alias methods accept an optional FieldFactoryInterface implementation as their only argument.

For example:

new CronExpression('@daily', new FieldFactory());

can be rewritten as:

CronExpression::daily(new FieldFactory());

Mutators

The following mutators are introduced:

  • every(int $step, int $position): self
  • range(string|int $start, string|int $end, int $position): self
  • list(iterable<string|int> $list, int $position): self
  • everyInRange(string|int $start, string|int $end, int $step, int $position): self

The methods allow updating the CronExpression in a more secure and predicable way by
supported basic CRON field grammar.

__call to improve DX

The __call() magic method provides a more developer-friendly API by generating expressive methods dynamically.

For example:

$expression = new CronExpression('@daily')
    ->every(5, CronExpression::WEEKDAY)
    ->list([8, 12, 15], CronExpression::HOUR)
    ->everyInRange(start: 12, end: 30, step:3, position:CronExpression::DAY)

can be rewritten as

$expression = CronExpression::daily()
    ->everyDaysOfWeek(5)
    ->listHours([8, 12, 15])
    ->everyInRangeDaysOfMonth(start: 12, end: 30, step:3);

Both snippets produce the same cron expression:

$expression->getExpression();
// return "0 8,12,15 12-30/3 * */5"

The same applies to retrieving individual cron fields:

$expression->getExpression(CronExpression::WEEKDAY);
// returns '*/5'

can be rewritten as

$expression->getDaysOfWeek();
// returns '*/5'

This allows developers to interact with cron fields using meaningful names instead of having to remember the CronExpression position constants and their values.

The following dynamic methods are supported:

Prefix Native Methods
every every
range range
list list
everyInRange everyInRange
set setPart
get getExpression

Each method prefix can be combined with one of the following suffixes:

Suffix CronExpression Constants
Minutes CronExpression::MINUTE
Hours CronExpression::HOUR
Months CronExpression::MONTH
DaysOfWeek CronExpression::WEEKDAY
DaysOfMonth CronExpression::DAY

@dragonmantank

Copy link
Copy Markdown
Owner

While I appreciate the work, please investigate how this library is already constructed. A lot of this PR has the bones of good ideas, but ends up duplicating a ton of exisiting code and existing classes. A quick glance shows that a lof of these ideas could probably be done in a backwards compatible way. I'm not closing this PR fully out to give you a chance to better implement these ideas.

@nyamsprod

Copy link
Copy Markdown
Author

@dragonmantank thanks for the quick answer.
I have seen the setPart method which partially overlaps with some of the methods introduced like minute or dayOfWeek which mutate a specific CRON field.

But I believe, you may prove me wrong, that the named constructors and the mutators like every, range, list and everyInRange are not present and help reducing errors when building CRON expression.

Again this is but a proposal I could rewrite it to include those methods in the CronExpression class directly if needed. But I wanted first to show the full scope of the PR and its implementation and start a discussion. Hence why I have added some open question. I probably should have added should those mutators or named constructors be added to the CronExpression class directly.

@nyamsprod

nyamsprod commented Jul 5, 2026

Copy link
Copy Markdown
Author

@dragonmantank What do you think if I update the PR do the following

echo CronExpression::daily()
    ->every(step: 5, position: CronExpression::WEEKDAY)
    ->list([8, 12, 15], CronExpression::HOUR)
    ->everyInRange(start: 12, end: 30, step:3, position: CronExpression::DAY), PHP_EOL;
// returns '0 8,12,15 12-30/3 * */5'

echo CronExpression::midnight(), PHP_EOL;
// returns '0 0 * * *'

which can even be rewritten using the __call magic method to:

echo CronExpression::daily()
    ->everyDaysOfWeek(step: 5)
    ->listHours([8, 12, 15])
    ->everyInRangeDaysOfMonth(start: 12, end: 30, step:3), PHP_EOL;
// returns '0 8,12,15 12-30/3 * */5'

echo CronExpression::midnight(), PHP_EOL;
// returns '0 0 * * *'

TL;DR:

  • we remove the Enum and the Builder
  • we add the missing methods to CronExpression directly.

@nyamsprod
nyamsprod force-pushed the feature/cron-expression-builder branch 8 times, most recently from 1ad27e4 to ef1ab69 Compare July 11, 2026 09:48
@nyamsprod

Copy link
Copy Markdown
Author

@dragonmantank I have updated the PR. It is now in a stable state that you can review. Looking forward for your review and remarks.

@nyamsprod
nyamsprod force-pushed the feature/cron-expression-builder branch 6 times, most recently from ba52055 to d54205f Compare July 11, 2026 11:01
@nyamsprod nyamsprod changed the title Introducing a Cron expression builder Improving Cron expression DX to reduce cron expression generation errors and typo Jul 11, 2026
@nyamsprod
nyamsprod force-pushed the feature/cron-expression-builder branch from d54205f to 2eab047 Compare July 11, 2026 12:58
@nyamsprod
nyamsprod force-pushed the feature/cron-expression-builder branch from 2eab047 to 43aaaee Compare July 11, 2026 13:05
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.

2 participants