Laravel queued jobs is one of the most fascinating features of the framework that makes automation and improving user experience a breeze.
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
andPlan
- Plan must have
datetime
propertiesupdated_at
andexpires_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.
Join to participate in the discussion