Bonus: Scheduling our Email Command
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
Note
symfony/scheduler now has an official recipe that creates src/Schedule.php
for you, so this step is no longer required.
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:
| // ... 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):
| // ... 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 | |
| ('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 | |
| ('# # * * *') | |
| 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 | |
| ('#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!
Thank you!! brilliant