Courses

Laravel User Timezones Project: Convert, Display, Send Notifications

Sending Notifications: Scheduled Job

We have fully prepared our system for the Notifications, and we have some data stored in our database. The next step is to create a system that would send them out at specific times.


Creating the Command

For this, we will create a new command:

app/Console/Commands/SendScheduledNotificationsCommand.php

<?php
 
namespace App\Console\Commands;
 
use App\Models\ScheduledNotification;
use Exception;
use Illuminate\Console\Command;
 
class SendScheduledNotificationsCommand extends Command
{
protected $signature = 'send:scheduled-notifications';
 
protected $description = 'Sends scheduled notifications to the users';
 
public function handle(): void
{
$notificationsToSend = ScheduledNotification::query()
->where('sent', false)
->where('processing', false)
->where('tries', '<=', config('app.notificationAttemptAmount'))
->where('scheduled_at', '<=', now()->format('Y-m-d H:i'))
->get();
 
// Lock jobs as processing
ScheduledNotification::query()
->whereIn('id', $notificationsToSend->pluck('id'))
->update(['processing' => true]);
 
foreach ($notificationsToSend as $notification) {
try {
dispatch(new ProcessNotificationJob($notification->id));
} catch (Exception $exception) {
$notification->increment('tries');
$notification->update(['processing' => false]);
}
}
}
}

Which will contain the logic to check for any Notifications that need to be sent and trigger their sending. We will also lock the Notifications as processing so that we don't send the same Notification twice.

Let's add the missing configuration option:

config/app.php

// ...
'aliases' => Facade::defaultAliases()->merge([
// 'Example' => App\Facades\Example::class,
])->toArray(),
 
'notificationAttemptAmount' => 5
// ...

And trigger the new command in our Scheduler to run every minute:

app/Console/Kernel.php

protected function schedule(Schedule $schedule): void
{
// ...
$schedule->command('send:scheduled-notifications')->everyMinute();
}

This ensures that we will check for any Notifications that need to be sent every minute and won't miss any Notifications.


Processing the Notifications

Now that we have the command, we need to implement the Job that will process the Notifications and send them out:

app/Jobs/ProcessNotificationJob.php

 
use App\Models\ScheduledNotification;
use App\Notifications\BookingReminder1H;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
 
class ProcessNotificationJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
private ScheduledNotification $notification;
 
public function __construct(int $notificationID)
{
try {
$this->notification = ScheduledNotification::query()
->with(['user'])
->withWhereHas('notifiable')
->findOrFail($notificationID);
} catch (Exception $exception) {
// Backup, just try to get the notification by id and fail the job
$this->notification = ScheduledNotification::query()
->find($notificationID);
$this->fail($exception);
}
}
 
public function handle(): void
{
if ($this->notification->sent || $this->notification->tries >= config('app.notificationAttemptAmount')) {
return;
}
if (!$this->notification->notifiable) {
// Makes sure that the notifiable is still available
$this->fail();
return;
}
try {
switch ($this->notification->notification_class) {
case BookingReminder1H::class:
$this->notification->user->notify(new BookingReminder1H($this->notification->notifiable));
break;
}
 
$this->notification->update(['processing' => false, 'sent' => true, 'sent_at' => now()]);
} catch (Exception $exception) {
$this->fail($exception);
}
}
 
public function fail($exception = null)
{
$this->notification->update(['processing' => false]);
$this->notification->increment('tries');
}
}

It might seem like a lot is going on here, but I'll try to explain the basic concept of what you see:

  • Construct - takes the ScheduledNotification ID and attempts to retrieve the information. If that fails - it will call the fail() method and fail the Job.
  • fail() method - increments the tries and sets the processing to false to allow another worker to pick up the Job and try again.
  • At the beginning of the handle() method we are looking at the Notification and checking if it's already sent or if it has reached the maximum amount of tries. If so - we just return and do nothing.
  • There's a switch statement that will trigger the Notification based on the class name. While it's not the perfect solution - it is great to illustrate the principles.
  • Each case calls a user->notify() method with the Notification class and the notifiable Model. This will send a Notification to the user.
  • After the Notification is sent, we update the Notification to be marked as sent and set the sent_at timestamp. This will allow us to track the Notifications that were sent and when.
  • If any of the above fails, we will call the fail() method and increment the tries.

That's it! We have a fully working Notification system that will send out Notifications to our users based on the schedule we have set up.

Previous: Adding Scheduled Notifications to DB
avatar

Hi, I have some questions as below:

  1. Why don't you use "retry" feature in Job class, for example, add public $tries = 5 to Job class and let laravel handle the rest. Then instead of calling $this->fail(), we can release the job manually to the queue to retry later.
  2. In this lesson, which queue driver are you using? Laravel support many driver such as database, redis.....
  3. I don't see how to setup cron entry (to trigger the scheduler every minute) and supervisor to manage the queue worker. It would be great if you can include the guide on how to setup these tool and queue driver.
  4. In SendScheduledNotificationsCommand class, I think we can use chunkById method to fetch the data, it will help to reduce the memory usage, avoid out of memory in case there are a lot of records in database
avatar

Hi,

  1. I have implemented a custom logic here to track the attempts on the notification itself and stop it manually. While it can be done with tries, it might not be what is expected and this way, you can for example change the retries amount on the fly. For example, change the config call to database settings table call and you can define per-notification retries, which is quite great.
  2. To be fully honest, it usually does not matter. There are some specifics but by default a standard is either database or redis. In our case it was redis
  3. Setting up the queue workers/scheduler are dependant on your deployment process and server you are using. Some people might run it via Forge, others might run it via something else. This would make the section huge, and honestly it is quite clear on documentation in Laravel itself.
  4. That might be a good addition here, but not sure if majority of people need it or would understand what it does :)

In any case, thanks for the feedback! We'll see what we can adjust and I'll let you know if we do!

avatar
You can use Markdown
avatar

Hi, does the system keep sending notifications automatically or do we need to do more setups to send these notifications? Thanks

avatar
You can use Markdown
avatar
You can use Markdown