Zubair Mohsin

How Laravel uses `dragonmantank/cron-expression` package in Task Scheduling

From the About section of dragonmantank/cron-expression package on GitHub:

CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due.

This package helps in dealing with CRON jobs in following ways:

  • Determine if a cron job is due running?
  • When will a job run next?
  • When a cron job was previously ran?

Let’s take a look at implementation of these features in CronExpression class of this package.

<?php

namespace Cron;

class CronExpression
{
    /**
     * Determine if the cron is due to run based on the current date or a
     * specific date.  This method assumes that the current number of
     * seconds are irrelevant, and should be called once per minute.
     *
     * @param string|\DateTimeInterface $currentTime Relative calculation date
     * @param null|string               $timeZone    TimeZone to use instead of the system default
     *
     * @return bool Returns TRUE if the cron is due to run or FALSE if not
     */
    public function isDue($currentTime = 'now', $timeZone = null): bool
    {
        $timeZone = $this->determineTimeZone($currentTime, $timeZone);

        if ('now' === $currentTime) {
            $currentTime = new DateTime();
        } elseif ($currentTime instanceof DateTime) {
            $currentTime = clone $currentTime;
        } elseif ($currentTime instanceof DateTimeImmutable) {
            $currentTime = DateTime::createFromFormat('U', $currentTime->format('U'));
        } elseif (\is_string($currentTime)) {
            $currentTime = new DateTime($currentTime);
        }

        Assert::isInstanceOf($currentTime, DateTime::class);
        $currentTime->setTimezone(new DateTimeZone($timeZone));

        // drop the seconds to 0
        $currentTime->setTime((int) $currentTime->format('H'), (int) $currentTime->format('i'), 0);

        try {
            return $this->getNextRunDate($currentTime, 0, true)->getTimestamp() === $currentTime->getTimestamp();
        } catch (Exception $e) {
            return false;
        }
    }

     /**
     * Get a next run date relative to the current date or a specific date
     *
     * @param string|\DateTimeInterface $currentTime      Relative calculation date
     * @param int                       $nth              Number of matches to skip before returning a
     *                                                    matching next run date.  0, the default, will return the
     *                                                    current date and time if the next run date falls on the
     *                                                    current date and time.  Setting this value to 1 will
     *                                                    skip the first match and go to the second match.
     *                                                    Setting this value to 2 will skip the first 2
     *                                                    matches and so on.
     * @param bool                      $allowCurrentDate Set to TRUE to return the current date if
     *                                                    it matches the cron expression.
     * @param null|string               $timeZone         TimeZone to use instead of the system default
     *
     * @throws \RuntimeException on too many iterations
     * @throws \Exception
     *
     * @return \DateTime
     */
    public function getNextRunDate($currentTime = 'now', int $nth = 0, bool $allowCurrentDate = false, $timeZone = null): DateTime
    {
        return $this->getRunDate($currentTime, $nth, false, $allowCurrentDate, $timeZone);
    }

    /**
     * Get a previous run date relative to the current date or a specific date.
     *
     * @param string|\DateTimeInterface $currentTime      Relative calculation date
     * @param int                       $nth              Number of matches to skip before returning
     * @param bool                      $allowCurrentDate Set to TRUE to return the
     *                                                    current date if it matches the cron expression
     * @param null|string               $timeZone         TimeZone to use instead of the system default
     *
     * @throws \RuntimeException on too many iterations
     * @throws \Exception
     *
     * @return \DateTime
     *
     * @see \Cron\CronExpression::getNextRunDate
     */
    public function getPreviousRunDate($currentTime = 'now', int $nth = 0, bool $allowCurrentDate = false, $timeZone = null): DateTime
    {
        return $this->getRunDate($currentTime, $nth, true, $allowCurrentDate, $timeZone);
    }
}

The real meat is getRunDate() method. But its implementation is huge, it will only increase length of the blog. Here you can find full source code.

Usage in schedule:run command

Laravel leverages this package in Task Scheduling. Let’s say we make a command named say:hello and we schedule it hourly() inside Console\Kernel.php like below:

    /**
     * Define the application's command schedule.
     *
     * @return void
     */
    protected function schedule(Schedule $schedule)
    {
        $schedule->command('say:hello')->hourly();
    }

Now, let’s see what happens when we run schedule:run command. Below is a ripped off version of schedule:run command’s handle method.

    public function handle(Schedule $schedule, ...)
    {
        foreach ($this->schedule->dueEvents($this->laravel) as $event) {
            if (! $event->filtersPass($this->laravel)) {
                $this->dispatcher->dispatch(new ScheduledTaskSkipped($event));

                continue;
            }

            if ($event->onOneServer) {
                $this->runSingleServerEvent($event);
            } else {
                $this->runEvent($event);
            }
        }
    }

As we can see it loops through all due events , checks if given filters for the event do not pass, it skips that event, otherwise run that event.

Let’s take a look at dueEvents() method on Schedule class and understand what are events.

    /**
     * Get all of the events on the schedule that are due.
     *
     * @param  \Illuminate\Contracts\Foundation\Application  $app
     * @return \Illuminate\Support\Collection
     */
    public function dueEvents($app)
    {
        return collect($this->events)->filter->isDue($app);
    }

    /**
     * Get all of the events on the schedule.
     *
     * @return \Illuminate\Console\Scheduling\Event[]
     */
    public function events()
    {
        return $this->events;
    }

dueEvents() return a collection of Illuminate\Console\Scheduling\Event objects, filtering these objects based on if they are due or not.

Now, let’s head over to Illuminate\Console\Scheduling\Event to see the implementation of isDue() method.

    /**
     * Determine if the given event should run based on the Cron expression.
     *
     * @param  \Illuminate\Contracts\Foundation\Application  $app
     * @return bool
     */
    public function isDue($app)
    {
        if (! $this->runsInMaintenanceMode() && $app->isDownForMaintenance()) {
            return false;
        }

        return $this->expressionPasses() &&
               $this->runsInEnvironment($app->environment());
    }

      /**
     * Determine if the Cron expression passes.
     *
     * @return bool
     */
    protected function expressionPasses()
    {
        $date = Carbon::now();

        if ($this->timezone) {
            $date->setTimezone($this->timezone);
        }

        return CronExpression::factory($this->expression)->isDue($date->toDateTimeString());
    }

isDue() method checks for maintenance first, after that it calls expressionPasses() method, where we can finally see CronExpression class being used to determine if the cron expression of an event ( scheduled command ) is due .

Usage in schedule:list command

php artisan schedule:list command outputs a table on the command line like shown below. It contains information about all schedule commands, e.g. their interval, description and when a command is next due.

Let’s take a look at the handle method of this command.

    /**
     * Execute the console command.
     *
     * @param  \Illuminate\Console\Scheduling\Schedule  $schedule
     * @return void
     * @throws \Exception
     */
    public function handle(Schedule $schedule)
    {
        foreach ($schedule->events() as $event) {
            $rows[] = [
                $event->command,
                $event->expression,
                $event->description,
                (new CronExpression($event->expression))
                            ->getNextRunDate(Carbon::now())
                            ->setTimezone($this->option('timezone', config('app.timezone'))),
            ];
        }

        $this->table([
            'Command',
            'Interval',
            'Description',
            'Next Due',
        ], $rows ?? []);
    }

As we can see, in order to get information about Next Due, Laravel utilises CronExpression class and it’s getNextRunDate() method.

Interesting facts about cron-expression package in Laravel context

  • It was first known as mtdowling/cron-expression , which was abandoned on 20 Dec, 2019 by Chris Tankersley
  • After that forked and maintained by Chris himself under dragonmantank/cron-expression
  • Taylor Otwell first used this package in Laravel v5.0 when he started working on scheduled commands back in 2014
  • Link to Taylor’s commit on GitHub when he first used this package in Laravel

I hope you find this blogpost useful. Next up we'll see how Laravel uses egulias/email-validator package. Stay in the loop by following me on Twitter