Menu

Alternative Task Scheduling in Laravel - solution for proc_open disabled (CRON Scheduler)

Categories: PHP


Published 2020-10-28 17:43


Is Laravel scheduler not working on your shared hosting when you're executing CRON? This may be due to the disabled PHP function "proc_open", which is required by Laravel components. In this article I'll show you how to make an alternative and simple scheduler.

Table of contents:

Introduction: We can't use Artisan, so we need to make our own Tasks Scheduler

Why Artisan doesn't work?

In a lot of shared hostings you can find out that PHP "proc_open" function is disabled for security reasons. Unfortunetly, it means that you cannot use the default Laravel scheduler, cause it uses Symfony component called Process, which requires "proc_open" function. Here is the proof:

namespace Symfony\Component\Process;
(...)
public function __construct($command, string $cwd = null, array $env = null, $input = null, ?float $timeout = 60)
{
    if (!\function_exists('proc_open')) {
        throw new LogicException('The Process class relies on proc_open, which is not available on your PHP installation.');
    }

( If you're not sure if "proc_open" function is disabled, run phpinfo() and look if it is on the "disable_functions" list. )

Well, it's a pity you can't use the default Laravel tool, but I can cheer you up - making a scheduler isn't as difficult as it seems to be.

Quick comparison and possibilities

Our scheduler will be very similarly in usage compared to the Kernel from Laravel. Tasks and their schedules will be defined inside the one function like in Kernel class. Also setting the schedule will be very easy and similar. Here are a few examples:

Default Kernel:

namespace App\Console;

class Kernel extends ConsoleKernel{

    protected function schedule(Schedule $schedule){
        $schedule->command('cron:dummy-task')->dailyAt('07:40');

        $schedule->command('cron:dummy-task')->weeklyOn(7, '8:00');

        $schedule->command('cron:dummy-task')->everyThirtyMinutes();

        $schedule->command('cron:dummy-task')->weekdays()->dailyAt('12:20');
    }

}

Usage of default scheduler:

Artisan::call('schedule:run');

Our TasksScheduler:

namespace App\Console;

class TasksScheduler{

    public function __construct(){
        $this->commands = [
            ScheduledTask::schedule( DummyTask::class )->dailyAt(7, 40)

            ScheduledTask::schedule( DummyTask::class )->staticTime(7, 8, 0)

            ScheduledTask::schedule( DummyTask::class )->interval(30)

            ScheduledTask::schedule( DummyTask::class )->weekdaysAt(12,20)
        ];
    }

Usage of our scheduler:

TasksScheduler::run();

You'll be able to run a task:

  • periodically, e.g. every hour (interval)
  • daily at a specific time
  • only on working days / weekends
  • at a specific day at a specific time (or times)

Furthermore, all of these options (except an interval) can be combined together.

Solution: Making custom scheduler in 3 steps:

Let's start making our scheduler in 3 easy steps.

1. TasksScheduler - the file we'll be using

namespace App\Console;

use App\Console\Commands\WeeklyReportForEmployer;

class TasksScheduler{

    public function __construct(){
        $this->commands = [
            ScheduledTask::schedule( WeeklyReportForEmployer::class )->everyMinute()
        ];
    }

    public static function run(){
        return ( new self() )->executeTasksForThisTime();
    }

    public function executeTasksForThisTime(){
        $tasksToExecute = [];

        foreach( $this->commands as $command ){
            if( $command->isTimeToExecute() )
                $tasksToExecute[] = $command;
        }

        foreach( $tasksToExecute as $task ){
            try{
                $task->handle();
            }catch(\Exception $e){
                continue;
            }
        }
    }
}

 

2. ScheduledTask - the core of schedule settings

The class is a replacement of Illuminate\Console\Scheduling\Schedule and it behaves very similar to this one (it's usage has been presented in the first section). Moreover, it inherits from Command class and your commands will inherit from it, so they will be still compatible with default Artisan scheduler.

This is how our class looks like:

namespace App\Console;

use App\Helpers\CarbonHelper;

use Illuminate\Console\Command;

class ScheduledTask extends Command{
    // Two possible modes of a schedule
    private $staticTimes = [];
    private $intervalMinutes = null;

    public static function schedule( $command ){
        return new $command();
    }

    public function isTimeToExecute(){
        if( $this->intervalMinutes ){
            $status = ( CarbonHelper::getMinutesSinceStartOfDay() % $this->intervalMinutes == 0 );
        }else{
            CarbonHelper::getDayOfWeekAndHourAndMinute($day, $hour, $minute);

            $status = false;
            foreach( $this->staticTimes as $time ){
                if( $day == $time['day'] && $hour == $time['hour'] && $minute == $time['minute'] ){
                    $status = true;
                    break;
                }
            }
        }

        return $status ? true : false;
    }

    public function every( $minutes ){
        $this->setIntervalMinutes($minutes);

        return $this;
    }

    public function everyMinute(){
        $this->setIntervalMinutes(1);

        return $this;
    }

    public function everyThirtyMinutes(){
        $this->setIntervalMinutes(30);

        return $this;
    }

    public function everyHour(){
        $this->setIntervalMinutes(60);

        return $this;
    }

    public function staticTime($day, $hour, $minute ){
        $this->validateStaticTime($day, $hour, $minute);

        $this->setStaticTime($day, $hour, $minute);

        return $this;
    }

    public function dailyAt( $hour, $minute ){
        $this->validateStaticTime(null, $hour, $minute);

        for( $day = 1; $day <= 7; $day++ )
            $this->setStaticTime($day, $hour, $minute);

        return $this;
    }

    public function weekdaysAt( $hour, $minute ){
        $this->validateStaticTime(null, $hour, $minute);

        for( $day = 1; $day <= 5; $day++ )
            $this->setStaticTime($day, $hour, $minute);

        return $this;
    }

    public function weekendsAt( $hour, $minute ){
        $this->validateStaticTime(null, $hour, $minute);

        for( $day = 6; $day <= 7; $day++ )
            $this->setStaticTime($day, $hour, $minute);

        return $this;
    }

    private function setIntervalMinutes( $minutes ){
        $this->intervalMinutes = $minutes;
    }

    private function setStaticTime( $day, $hour, $minute ){
        $this->intervalMinutes = null;
        $this->staticTimes[] = compact('day', 'hour', 'minute');
    }

    private function validateStaticTime( $day = null, $hour, $minute ){

        if( $day !== null && ($day < 1 || $day > 7) )
            throw new \Exception('Day must be a number between 1 and 7.');

        if( $hour < 0 || $hour > 23 )
            throw new \Exception('Hour must be a number between 0 and 23.');
        
        if( $minute < 0 || $minute > 59 )
            throw new \Exception('Minute must be a number between 0 and 59.');
    }
}

CarbonHelper:

To avoid messing up our code and to be able to use its fragments in the future, we'll create a separate class based on Carbon extension, which is included in Laravel by default.

namespace App\Helpers;

use Carbon\Carbon;

class CarbonHelper{

    public static function getDayOfWeekAndHourAndMinute(&$day, &$hour, &$minute){
        $currentTime = Carbon::now();
        // Carbon treats Sunday as the zero day of the week! Check how it behaves in your environment
        $day = ($currentTime->dayOfWeek === Carbon::SUNDAY ? 7 : $currentTime->dayOfWeek);
        $hour = $currentTime->hour;
        $minute = $currentTime->minute;
    }

    public static function getMinutesSinceStartOfDay(){
        $zeroTime = Carbon::createFromTimeString('00:00:00');
        $minutes = Carbon::now()->diffInMinutes( $zeroTime );

        return $minutes;
    }
}

 

3. Change the parent of commands

The last thing we need to do is to change the parent of already existing commands from Illuminate\Console\Command to App\Console\ScheduledTask. Note that those commands are still compatible with default Kernel class.

namespace App\Console\Commands;

use App\Console\ScheduledTask;
// use Illuminate\Console\Command;

class DummyTask extends ScheduledTask /* Command */ {
    protected $signature = 'cron:dummy-task';
    protected $description = 'Dummy task description.';

    public function handle()
    {
        //
    }
}

 

That's all!

Our scheduler is ready to use. All we need to do now is to set up schedules of commands in TasksScheduler's constructor using methods of ScheduledTask class. At the end just call "TasksScheduler::run()" in any method I had descriped in this article.


You are 339 person who read this post
How do you like it?
Very helpful: 1
Likes: 0
Knowledge search
Thematic areas
All categories
Stay longer!

Join newsletter and decide what content you want to receive.