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
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!