Login to bookmark this video
21.

Bonus: Scheduling our Email Command

|

Share this awesome video!

|

Hey! You're still here? Great! I have a bonus chapter for you.

One of our interns, Hugo, is complaining that he has to log in to our server and run the booking reminders command, every night at midnight. I don't know what the problem is - isn't that what interns are for?!

Installing Symfony Scheduler

But... I guess to be more robust, we should automate this in case he's sick or forgets. We could set up a CRON job... but that wouldn't be nearly as cool or flexible as using the Symfony Scheduler component. It's perfect for this. At your terminal, run:

composer require scheduler

Think of Symfony Scheduler as an add-on for Messenger. It provides its own special transport that, instead of a queue, determines if it's time to run a job. Each job, or task, is a messenger message, so it requires a message handler. You consume the schedule, like any messenger transport with the messenger:consume command.

make:schedule

Create a schedule with:

symfony console make:schedule

Transport name? Use default. Schedule name? Use the default: MainSchedule. Exciting!

It's possible to have multiple schedules, but for most apps, a single schedule is enough.

Configuring the Schedule

Check it out: src/Scheduler/MainSchedule.php. It's a service that implements ScheduleProviderInterface and is marked with the #[AsSchedule] attribute with the name default. The maker automatically injected the cache, and we'll see why in a second. The getSchedule() method is where we configure the schedule and add tasks.

This ->stateful() that we're passing $this->cache to is important. If the process that's running this schedule goes down - like our messenger workers stop temporarily during a server restart - when it comes back online, it will know all the jobs it missed and run them. If a task was supposed to run 10 times while it was down, it will run them all. That might not be desired so add ->processOnlyLastMissedRun(true) to only run the last one:

34 lines | src/Scheduler/MainSchedule.php
// ... lines 1 - 12
final class MainSchedule implements ScheduleProviderInterface
{
// ... lines 15 - 19
public function getSchedule(): Schedule
{
return (new Schedule())
// ... lines 23 - 29
->processOnlyLastMissedRun(true)
;
}
}

Bulletproof!

For more complex apps, you might be consuming the same schedule on multiple workers. Use ->lock() to configure a lock so that only one worker runs the task when its due.

Adding a Task

Time to add our first task! In ->add(), write RecurringMessage::. There are a few different ways to trigger a task. I like to use cron(). I want this task to run at midnight, every day, so use 0 0 * * *. The second argument is the messenger message to dispatch. We want to run the SendBookingRemindersCommand, but we can't add it here directly. Instead, use new RunCommandMessage() and pass the command name: app:send-booking-reminders (you can pass arguments and options here too):

34 lines | src/Scheduler/MainSchedule.php
// ... lines 1 - 12
final class MainSchedule implements ScheduleProviderInterface
{
// ... lines 15 - 19
public function getSchedule(): Schedule
{
return (new Schedule())
->add(
RecurringMessage::cron(
'0 0 * * *',
new RunCommandMessage('app:send-booking-reminders')
)
)
// ... lines 29 - 30
;
}
}

Debugging the Schedule

At your terminal, list our schedule's tasks by running:

symfony console debug:schedule

Oh, we have an error.

You cannot use "CronExpressionTrigger" as the "cron expression" package is not installed

Easy fix: copy the install command and run it:

composer require dragonmantank/cron-expression

Cool name! Now run the debug command again:

symfony console debug:schedule

Here we go, the output's a little wonky on this small screen, but you can see the cron expression, the message (and command), and the next runtime: tonight at midnight.

#[AsCronTask]

There's an alternate to schedule commands. In MainSchedule::getSchedule(), delete the ->add(). Then jump over to our SendBookingRemindersCommand and add another attribute: #[AsCronTask()] passing: 0 0 * * *:

// ... lines 1 - 19
#[AsCronTask('0 0 * * *')]
class SendBookingRemindersCommand extends Command
// ... lines 22 - 52

In your terminal, debug the schedule again to make sure it's still listed:

symfony console debug:schedule

And it is, pretty neat.

If you have a lot of tasks scheduled at the same time, like midnight, you might see a CPU spike at this time on your server. Unless it's super important that tasks run at a very specific time, you should spread them out. One way to do this of course, is to manually make sure they all have different cron expressions but... that's a bore.

Hashed Cron Expressions

For our app:send-booking-reminders command, I don't care when it runs, just that it runs once a day. We can use a hashed cron expression. In our expression, replace the 0's with #'s. The # means "pick a random, valid value for this part":

// ... lines 1 - 19
#[AsCronTask('# # * * *')]
class SendBookingRemindersCommand extends Command
// ... lines 22 - 52

Debug the schedule again:

symfony console debug:schedule

It's set to run at 5:11am. Run the command again:

symfony console debug:schedule

It's still 5:11am. Ok, so it's not truly random, the values are calculated deterministically based on the message details. In our case, the string app:send-booking-reminders. A different command with the same hash expression will have different values.

The Scheduler documentation has all the details on this. There's even aliases for common hashes. For instance, #mignight will pick a time between midnight and 3am. Use that for our expression:

// ... lines 1 - 19
#[AsCronTask('#midnight')]
class SendBookingRemindersCommand extends Command
// ... lines 22 - 52

and debug the schedule again:

symfony console debug:schedule

Oops, a typo, I'll fix that and run again:

symfony console debug:schedule

It's now scheduled to run every day at 2:11am. Cool!

Running the Schedule

We've configured our schedule, but how do we run it? Remember, schedules are just Messenger transports. The transport name is scheduler_<schedule_name>, in our case, scheduler_default. Run it with:

symfony console messenger:consume scheduler_default

On your production server, configure this to run in the background just like a normal messenger worker.

Alright, that's a quick rundown of the Scheduler component. Check out the documentation to learn more about it!

Happy coding and happy scheduling!