To use this site please enable javascript on your browser! Run a Laravel Job in the Background for a Definite Time With Intervals

We use cookies, as well as those from third parties, for sessions in order to make the navigation of our website easy and safe for our users. We also use cookies to obtain statistical data about the navigation of the users.

See Terms & Conditions

Run a Laravel Job in the Background for a Definite Time With Intervals

by Bryce van Andy 01:08 Aug 30 '21

Laravel queued jobs is one of the most fascinating features of the framework that makes automation and improving user experience a breeze.

Queueing Jobs
Illustration from Slideshare.net

If you have never used queues before, be sure to read the documentation first as we are not going to cover the basics.

Real World Application

Imagine we have a subscription based application, and users need to be notified multiple times before their subscription plan has expired.

We are going to create a job that monitors the user plan 2 weeks, 1 week, 3 days and 1 day before the actual expiration day in order to send a notification.

Creating and Dispatching the Job

Running the following command to create the MonitorPlanExpiration job class:

php artisan make:job MonitorPlanExpiration

In the app/Jobs directory, we will find the created job:

<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class MonitorPlanExpiration implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * Create a new job instance.
     *
     * @return void
     */
    public function __construct()
    {
        //
    }

    /**
     * Execute the job.
     *
     * @return void
     */
    public function handle()
    {
        //
    }
}

Our job will take the User as an argument, assuming every user will be subscribed to a single plan.

Whenever we are going to dispatch the job we must indicate the user dependency.

// The job
use App\Models\User;

class MonitorPlanExpiration extends ShouldQueue
{
    public function __construct(
        private User $user,
    ){}
}

// When a user is subscribed
use App\Jobs\MonitorPlanExpiration;

class SubscriptionController extends Controller
{
    public function store()
    {
        // Proceed with subscription processes...

        // Dispatching the job...
        MonitorPlanExpiration::dispatch(auth()->user());

        return view('subscription.success'); // Returning the user
    }
}

Remember, while using PHP versions lower than 8.0, we can't use constructor property promotion, so it becomes a bit longer:

// The job
use App\Models\User;

class MonitorPlanExpiration extends ShouldQueue
{
    private $user;

    public function __construct(User $user)
    {
        $this->user = $user;
    }
}

Notification and Job Logic

Now comes the important part on how the job will function. First, we will imagine we have a notification UpdateSubscriptionPlan created, that we want to send.

Since we want to start notifying the user 2 weeks before, why don't we start dispatching the job 2 weeks before the expiration day?

$user = auth()->user();

MonitorPlanExpiration::dispatch($user)
    ->delay($user->plan->expires_at->subWeeks(2));

Then in the job, we want to check if the plan has been recently updated. If not we send the notification:

public function handle()
{
    $plan = $this->user->plan;

    if ($plan->expires_at->diffInWeeks($plan->updated_at) > 2) {
        return;
    }

    $this->user->notify(new \App\Notifications\UpdateSubscriptionPlan);
}

Also we would like to notify the user again 1 week before the expiration day, we can release the job into the queue again depending on the number of attempts:

public function handle()
{
    $plan = $this->user->plan;

    if ($plan->expires_at->diffInWeeks($plan->updated_at) > 2) {
        return;
    }

    if ($this->attempts() === 1) {
        // Push job to the queue a week later
        $this->release(now()->addWeek());
    }

    $this->user->notify(new \App\Notifications\UpdateSubscriptionPlan);
}

To notify the user again 3 days before expiration, we push the job into the queue if it's the 2nd attempt:

public function handle()
{
    $plan = $this->user->plan;

    if ($plan->expires_at->diffInWeeks($plan->updated_at) > 2) {
        return;
    }

    if ($this->attempts() === 1) {
        $this->release(now()->addWeek());
    }

    if ($this->attempts() === 2) {
        // Push the job to the queue 3 days before expiry
        $this->release(now()->addDays(4));
    }

    $this->user->notify(new \App\Notifications\UpdateSubscriptionPlan);
}

Finally we want to notify the user for the last time one day before expiration:

public function handle()
{
    $plan = $this->user->plan;

    if ($plan->expires_at->diffInWeeks($plan->updated_at) > 2) {
        return;
    }

    if ($this->attempts() === 1) {
        $this->release(now()->addWeek());
    }

    if ($this->attempts() === 2) {
        $this->release(now()->addDays(4));
    }

    if ($this->attempts() === 3) {
        // Push the job to the queue a day before plan expiry
        $this->release(now()->addDays(2));
    }

    $this->user->notify(new \App\Notifications\UpdateSubscriptionPlan);
}

Notice how we can refactor all the if statements and clean up this code. Here is how each attempt depends on the days added:

  • Attempt 1 equals 7 days
  • Attempt 2 equals 4 days
  • Attempt 3 equals 2 days

With this in mind, we can use an expression to calculate the days without writing many ifs:

$days = $attempt === 1 ? 7 : (8 - 2 * $attempt);

From the above expression, we can now simplify our handler with a simple collection:

public function handle()
{
    $plan = $this->user->plan;

    if ($plan->expires_at->diffInWeeks($plan->updated_at) > 2) {
        return;
    }

    // Attempt 1 releases the job after 7 days, attempt 2 releases after 4 days & attempt 3 after 2 days
    collect(range(1, 3))
            ->filter(fn($attempt) => $this->attempts() === $attempt)
            ->map(fn($attempt) => $this->release(
                now()->addDays($attempt === 1 ? 7 : (8 - 2 * $attempt))
            ));

    $this->user->notify(new \App\Notifications\UpdateSubscriptionPlan);
}

If you are using a PHP version lower than 7.4 then the above code changes to:

collect(range(1, 3))
    ->filter(function ($attempt) {
        return $this->attempts() === $attempt;
    })
    ->map(function ($attempt) => {
        return $this->release(
            now()->addDays($attempt === 1 ? 7 : (8 - 2 * $attempt))
        )
    });

Tries and Job Expiration

Because the job handler has many attempts (3), we don't want Laravel to automatically fail our job since the default amount of attempts is 1 for a worker.

To do this, we need to explicitly declare when the job expires and also set the tries property to 0 so that we can have unlimited number of attempts.

class MonitorPlanExpiration extends ShouldQueue
{
    public $tries = 0;

    public function retryUntil()
    {
        // We may set that the job should expire after a month
        return now()->addMonth();
    }
}

Gotchas

In this application example, we have assumed the following:

  • There is an eloquent relationship between two entities User and Plan
  • Plan must have datetime properties updated_at and expires_at which Laravel automatically casts them as Carbon instances from the migration
  • Whenever a user updates their plan, make sure eloquent can reflect the change with the two datetime properties.

That's all folks.

Updated 02:08 Aug 30 '21

If you like this content, please consider buying me coffee.
Thank you for your support!

Become a Patron!