New Component: Scheduler
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeOne of the coolest new components is Scheduler, which came from Symfony 6.3. If you need to trigger a recurring task, like generate a weekly report, send some sort of heartbeat every 10 minutes, perform routine maintenance... or even something custom and weird, this component is for you. It's really neat! It deserves its own tutorial, but we'll worry about that later. Let's take it for a test drive.
Installing Scheduler
At your command line, install it with:
composer require symfony/scheduler symfony/messenger
Scheduler relies on Messenger: they work together! The process looks like this. You create a message class and handler, like you normally would with Messenger. Then you tell Symfony:
Yo! I want you to send this message to be handled every seven days, or every one hour... or something weirder.
Creating the Message Class & Handler
This means that step one is to generate a Messenger message. Run:
php bin/console make:message
Call it LogHello. Cool! Over here, it created the message class - LogHello
| // ... lines 1 - 2 | |
| namespace App\Message; | |
| final class LogHello | |
| { | |
| public function __construct() | |
| { | |
| } | |
| } |
and its handler, whose __invoke() method will be called when LogHello is dispatched through Messenger.
| // ... lines 1 - 2 | |
| namespace App\MessageHandler; | |
| use App\Message\LogHello; | |
| use Symfony\Component\Messenger\Attribute\AsMessageHandler; | |
| final class LogHelloHandler | |
| { | |
| public function __construct() | |
| { | |
| } | |
| public function __invoke(LogHello $message) | |
| { | |
| } | |
| } |
In LogHello, give it a constructor with public int $length.
| // ... lines 1 - 2 | |
| namespace App\Message; | |
| final class LogHello | |
| { | |
| public function __construct(public int $length) | |
| { | |
| } | |
| } |
This will help us figure out which message is being handled and when. In the handler, also add a constructor so we can autowire LoggerInterface $logger.
| // ... lines 1 - 5 | |
| use Psr\Log\LoggerInterface; | |
| // ... lines 7 - 9 | |
| final class LogHelloHandler | |
| { | |
| public function __construct(private LoggerInterface $logger) | |
| { | |
| } | |
| // ... lines 15 - 19 | |
| } |
Down in the method, use $this->logger->warning() - just so these log entries are easy to see - then str_repeat() to log a guitar icon $message->length times. I'll also log that number at the end.
| // ... lines 1 - 5 | |
| use Psr\Log\LoggerInterface; | |
| // ... lines 7 - 9 | |
| final class LogHelloHandler | |
| { | |
| public function __construct(private LoggerInterface $logger) | |
| { | |
| } | |
| public function __invoke(LogHello $message) | |
| { | |
| $this->logger->warning(str_repeat('🎸', $message->length).' '.$message->length); | |
| } | |
| } |
Message & handler check!
Creating the Schedule
Next up is to create a schedule that tells Symfony:
Yo, me again. Please dispatch a
LogHellomessage through messenger every 7 days.
Or in our case, every few seconds because I don't think you want to watch this screencast for the next week!
In src/, I don't have to do this, but I'll create a Scheduler directory. And inside, a PHP class called, how about, MainSchedule. Make this implement ScheduleProviderInterface.
| // ... lines 1 - 2 | |
| namespace App\Scheduler; | |
| // ... lines 4 - 6 | |
| use Symfony\Component\Scheduler\ScheduleProviderInterface; | |
| // ... lines 8 - 9 | |
| class MainSchedule implements ScheduleProviderInterface | |
| { | |
| // ... lines 12 - 14 | |
| } |
You can have multiple of these schedule providers in your system... or you can have one class that sets up all your recurring messages. Your call.
This class also needs an attribute called #[AsSchedule]. This has one optional argument: the schedule name, which, creatively, defaults to default. We'll see why that name is important soon. I'll use default.
| // ... lines 1 - 2 | |
| namespace App\Scheduler; | |
| use Symfony\Component\Scheduler\Attribute\AsSchedule; | |
| // ... line 6 | |
| use Symfony\Component\Scheduler\ScheduleProviderInterface; | |
| // ... line 8 | |
| class MainSchedule implements ScheduleProviderInterface | |
| { | |
| // ... lines 12 - 14 | |
| } |
Creating the Recurring Messages
Ok, go to Code -> Generate, or command+N on a Mac - to implement the one method we need: getSchedule().
| // ... lines 1 - 2 | |
| namespace App\Scheduler; | |
| use Symfony\Component\Scheduler\Attribute\AsSchedule; | |
| use Symfony\Component\Scheduler\Schedule; | |
| use Symfony\Component\Scheduler\ScheduleProviderInterface; | |
| class MainSchedule implements ScheduleProviderInterface | |
| { | |
| public function getSchedule(): Schedule | |
| { | |
| } | |
| } |
The code in here is beautifully simple and expressive. Return a new Schedule(), then add things to this by calling ->add(). Inside, for each "thing" you need to schedule, say RecurringMessage::. There are several ways to create these recurring messages. The easiest is every(), like every 7 days or every 5 minutes. You can also pass a cron syntax, or call trigger(). In that case, you would define your own logic for exactly when you want your weird message to be triggered.
Use every() and pass 4 seconds. Every 4 seconds, we want this new LogHello message to be dispatched to Messenger. Copy that, then create another for every 3 seconds.
| // ... lines 1 - 4 | |
| use App\Message\LogHello; | |
| // ... line 6 | |
| use Symfony\Component\Scheduler\RecurringMessage; | |
| // ... lines 8 - 11 | |
| class MainSchedule implements ScheduleProviderInterface | |
| { | |
| public function getSchedule(): Schedule | |
| { | |
| return (new Schedule())->add( | |
| RecurringMessage::every('4 seconds', new LogHello(4)), | |
| RecurringMessage::every('3 seconds', new LogHello(3)), | |
| ); | |
| } | |
| } |
We're done!
Consuming the Scheduler Transport
The result of creating a schedule provider is that a new Messenger transport is created. To get your recurring messages to process, you need to have a worker that's running the messenger:consume command.
At your terminal, run bin/console messenger:consume with a -v so we can see the log messages from our handler. Then pass the name of the new, automatically-added transport: scheduler_default... where default is the name we used in the #[AsSchedule] attribute.
php bin/console messenger:consume -v scheduler_default
Hit it, wait about 3 seconds... there it is! Four! Then the 3 one comes up again, and four, then three. After 12 seconds, they should execute, yep, at almost the exact same moment. Technically, this one was dispatched first, and then that one was dispatched immediately after.
But, let me stop nerding out and back up: it's working! It's beautiful!
How does Scheduler Work?
How is it working? I wondered that same thing. When the worker command starts, it loops over every RecurringMessage, calculates the next runtime of each, and uses that to create a list - called the "heap" - of upcoming messages. Then it loops forever. As soon as the current time matches - or is later than - the scheduled runtime of the next message in the heap, it takes that message and dispatches it through Messenger. It then asks this recurring message for its next runtime and puts that inside the heap.
And this process just... continues forever.
Make your Schedule Stateful
Though there is one problem hiding in plain sight: if we restart the command, it creates the schedule from scratch. That means that it waits a fresh new three seconds and four seconds before it dispatches the messages.
In a real app, this will be a problem. Imagine you have a message that runs every seven days. For some reason, after 5 days, your messenger:consume command exits and is restarted. Because of this, your recurring message will now run seven days after this restart: so it will run on day 12. If it keeps getting restarted, your message may never run!
This is not workable. And so, in the real world, we always make our schedule stateful. And this easy. Create a __construct method and autowire a private CacheInterface: the one from Symfony cache.
| // ... lines 1 - 9 | |
| use Symfony\Contracts\Cache\CacheInterface; | |
| // ... lines 11 - 12 | |
| class MainSchedule implements ScheduleProviderInterface | |
| { | |
| public function __construct( | |
| private CacheInterface $cache, | |
| ) | |
| { | |
| } | |
| // ... lines 21 - 29 | |
| } |
Down below, call ->stateful() and pass $this->cache.
| // ... lines 1 - 9 | |
| use Symfony\Contracts\Cache\CacheInterface; | |
| // ... lines 11 - 12 | |
| class MainSchedule implements ScheduleProviderInterface | |
| { | |
| public function __construct( | |
| private CacheInterface $cache, | |
| ) | |
| { | |
| } | |
| // ... line 21 | |
| public function getSchedule(): Schedule | |
| { | |
| return (new Schedule())->add( | |
| // ... lines 25 - 26 | |
| ) | |
| ->stateful($this->cache); | |
| } | |
| } |
Also, open services.yaml. In an earlier tutorial, I added some config that effectively disabled the cache in the dev environment. Remove that so we have a proper cache.
Ok, stop the worker and restart it. The first time we do this, it's going to have the same behavior as before: wait three seconds and four seconds. There we go.
But now, stop this, wait a few seconds and watch what happens when I restart. It catches up! Those messages happened immediately!
The state keeps track of the last time Scheduler checked for messages. And so, if your worker gets turned off for a bit, when it restarts, it reads that time and uses it as its starting time so it can catch up with all the messages that it missed.
It does mean that you may have some messages that are executed multiple times immediately, but it won't miss anything.
Multiple Workers: Lock your Schedule
Oh, and if you plan to have multiple workers for your scheduler transport, you'll also need to add a lock to the schedule. This is easy and covered in the docs: autowire the lock factory, then call ->lock() to pass in a new lock. This will make sure that two workers don't grab the same recurring message at the same time and both process it.
All right team, that's all I've got! Thanks for hanging out. If you have any questions about upgrading or hit a problem we didn't mention, we're here for you down in the comments. And let us know if you have a victory: we love hearing success.
All right, friends. See you next time!
18 Comments
I have a collection of Symfony console commands that I would like to run using the scheduler at different times but I would like to be able to manage these commands and their CRON expression from the database.
So I have created a `ScheduledCommand ' entity with properties such as
app:send-api-create,*/5 * * * *, andI manage these ScheduledCommands objects from the UI.
Then I have the Schedule.php, what would be the best way to update the scheduler in real-time, for instance, if the command is changed (arguments/options updated) or the CRON expression is altered in the database, it also reflects in the scheduler and consumer picks up the new changes in real time?
Currently, if I have the consumer running in the terminal and any command is changed from the UI, it won't be reflected in the consumer.
bin/console messenger:consume scheduler_default -vvHey @GhazanfarMir
I think you can do it by implementing a custom scheduler provider that reads your database and set up the schedule. Something like this
Then, you can start the consumer worker to only dispatch a low amount of messages so it restarts often and it catch up with the changes.
Also, here's info on how to modify scheduled messages in real time: https://symfony.com/doc/current/scheduler.html#modifying-scheduled-messages-in-real-time
Hope it helps. Cheers!
Does anybody know how to deploy a symfony app with scheduler on azure? I have an ASP with a PHP webapp on azure. I think best practice would be to use a docker image instead?
If so, which docker image would you recommend for production?
Hey Mike-Profile,
Well, probably Azure docs will suggest some? Maybe they have their own Docker images, not sure. Can't really suggest a good one as we don't use Docker, but I think if there's no any Azure-specific images, you can always go with the official Docker images for the technologies you're using, I bet that's a good fallback option.
About specific deploy, I haven't used Azure personally, but usually you can deploy everything with Ansible and Ansistrano. Take a look at the Ansistrano course we have on SymfonyCasts: https://symfonycasts.com/screencast/ansistrano
I hope that helps!
Cheers!
Is this correct?
"Previously, Symfony had separate commands for processing scheduled tasks and regular messages:
These processes operated independently, requiring separate execution to handle both scheduled and regular messages. 
In Symfony 7.2, the Scheduler has been fully integrated into the Messenger system. Scheduled tasks are now treated as Messenger messages, eliminating the need for a separate scheduler:consume command. Instead, the messenger:consume command processes both regular and scheduled messages.
Therefore, to process scheduled tasks in Symfony 7.2, you should use the messenger:consume command with the appropriate transport:
php bin/console messenger:consume async -vv
This command will handle both regular and scheduled messages, streamlining the message processing workflow."
Hey @Mike-Profile!
No, I don't believe there was ever a
scheduler:consume. Even when the Scheduler was introduced in 6.3, it wasmessenger:consume.Where did you find that note?
--Kevin
Thank you! It was my mistake, a personal readme note in combination with AI hallucination.
Hah, no problem. I suspected AI hallucination!
I use to clear cache every time I push changes. It affects the scheduler cache? clear cache with cache:clear after pushing changes is not a good practice?
Hey @Adrian_Ntss!
You have configured a stateful schedule? You'll need to use a cache that doesn't get cleared when you deploy (or run
cache:clearon production). This could be something like Redis or even a different filesystem cache in a different directory (that's kept between deploys).In the documentation they use "(new Schedule())->with()", but here you are using "(new Schedule())->add()".
Why?
Hey @Nick-F! Good catch!
These methods are almost the same but have one subtle difference, and this only applies under certain circumstances.
with()can be used at runtime to add additional tasks dynamically (without restarting the schedule).add()ensures the schedule is reset before adding.If using the Scheduler the standard way, and how we use it in this chapter, it doesn't matter which method you use. The schedule is built when it's first started so it's always reset.
If you require adding tasks at runtime, a more advanced usage, you'd use
with().Hope this helps!
Hello,
I don't get one thing...
I've created a scheduler, which executes every 7 seconds (I don't add any lock, as you can see):
This scheduler sometimes runs very hard tasks (> 7 seconds), and it looks like next scheduler tick isn't triggered until previous tick isn't finished. If some scheduler tick executes, let's say, 2 minutes, there is no next scheduler ticks (every 7 seconds) during those 2 minutes.
I expected, that scheduler starts some task and handles it in background, so "7 seconds" works like a clock (like unix cron). But it looks like some blocking process is present, and next scheduler ticks "waiting" for previous task finishing.
Am I right or I miss something?
PS. Just in case - here are my scheduler process settings (in supervisor conf):
Hey @BooleanType!
Yes, when a scheduled message is dispatched, it blocks the process (preventing schedule ticks). As you've noted, this is unlike a standard unix cron job. From what you describe, the schedule likely never catches up.
I think your best bet would be to have another worker running a standard
messenger:consumeon a non-schedule transport (likeasync). Then, when creating the schedule, wrap your message withRedispatchMessage:This will have the schedule only dispatch another message, freeing it up to be available for the next tick.
You can find some documentation on this at the end of this section.
Let me know if you have any luck with this!
Thank you for such a detailed answer, now it’s clearer to me!
For now, I gave preference to the good old cron, leaving the scheduler “until better times” :)
Is it good to use this component for database and/or images upload daily backups?
Hey @Kirill
I've not used it for tasks like that but I'd say yes, it should work, the only issue I can think of is if the backup takes too much time to complete, it may delay other tasks to execute. If that's the case you could configure it to run at midnight (or at your lowest traffic hours), or set up a dedicated worker for this Schedule
Cheers!
Thank you
"Houston: no signs of life"
Start the conversation!