Symfony Scheduler — How it Really Works

Filip Horvat
4 min readFeb 9, 2024

--

Symfony released the Scheduler component a few months ago in version 6.3, and I find it to be a very cool and useful tool.

This story is not so much a guide on how to use Symfony Scheduler; instead, it tries to explain what is happening under the hood.

Previously, you had to use the Linux cron service in combination with non-official Symfony packages or you custom implementation for handling recurring jobs.

But now, you can forget about the Linux cron service, and I think that’s great because it eliminates one moving part on server. Setting up recurring jobs with Symfony is now easier, especially if you already use Symfony Messenger.

The Symfony Scheduler is, in fact, more robust and flexible than a solution using the Linux cron service. With Symfony Scheduler, you can define more sophisticated solutions that were not possible with the Linux cron service. For example now you can set rule “every 1 second”.

If you want to discover how to use Symfony Scheduler and explore its possibilities, take a look at the official documentation:

https://symfony.com/doc/current/scheduler.html

While the documentation is good, personally, I find that it doesn’t explain what’s going on under the hood as thoroughly. In this regard, I will try to provide a more in-depth explanation.

Example

We will create a recurring job with Symfony Scheduler that stores the date in the dummy log every 5 seconds.

Setting up Symfony Scheduler was surprisingly fast and trouble-free for me.

Here are 5 simple steps to make it work:

  1. Install Symfony Scheduler
composer require symfony/scheduler

2. Create a Message

<?php

namespace App\Scheduler\Message;

class Test
{
}

3. Create a Message Handler

<?php

namespace App\Scheduler\Handler;

use App\Scheduler\Message\Test;
use Carbon\Carbon;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
class TestHandler
{
public function __construct(
private readonly KernelInterface $kernel,
) {
}

public function __invoke(Test $message): void
{
$path = $this->kernel->getProjectDir().'/var/log/cron_test.log';

file_put_contents($path, Carbon::now()->format('Y-m-d H:i:s')."\n", FILE_APPEND);
}
}

4. Add ScheduleProvider

<?php

namespace App\Scheduler;

use App\Scheduler\Message\MoveInTenants;
use App\Scheduler\Message\MoveOutTenants;
use App\Scheduler\Message\Test;
use App\Scheduler\Message\Test2;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;

#[AsSchedule]
class ScheduleProvider implements ScheduleProviderInterface
{
public function getSchedule(): Schedule
{
return (new Schedule())->add(
RecurringMessage::every('5 seconds', new Test()),
);
}
}

5. run messenger consume

php bin/console messenger:consume scheduler_default

And that’s it — the current time is now written to the log file every 5 seconds.

How it works

I asked myself, ‘Okay, this command is from the messenger component:

php bin/console messenger:consume scheduler_default

And all it does is run indefinitely, continuously searching for new messages on your transport and handling them if there are any. But the question arises: who generates the messages that are consumed?

The trick is that messages are not pre-created and added to the transport. Instead, they are dynamically generated on the fly when the messenger:consume command queries the transport to check for any messages. This is achieved with a messenger utility called custom transport.

https://symfony.com/doc/current/messenger/custom-transport.html

So basically, in simple terms, the scheduler is hooked into the messenger’s giveMeNextMessageFromTheTransport() function(this is not really function name, I am just trying to explain it in simple terms). This function is invoked by the messenger:consume command every second. The system doesn't check if there are any messages that are waiting to be consumed; instead, it recalculates if it should generate a new message based on defined rules, like 'every 5 seconds, and then pass that message or more messages to the messenger:consumecommand.

So if you have this command running:

php bin/console messenger:consume scheduler_default

This is happening in the loop:

while (true){

1. messenger:consume command ask transport if there are any messages
to be consumed

2.the transport calculate if some message should be created on the fly
and returns message if it should or tells messenger:consume command
that there is nothing to consume

}

If we have a rule set ‘every 5 seconds,’ and suppose the command starts, for example, at 2024–02–09 10:10:20. Here’s what happens at each second.

At first run there is no any message which is generated, because the first message will be created 5 seconds later.

Here is what the timeframe looks like:

2024-02-09 10:10:20 - no
2024-02-09 10:10:21 - no
2024-02-09 10:10:22 - no
2024-02-09 10:10:23 - no
2024-02-09 10:10:24 - no
2024-02-09 10:10:25 - YES
2024-02-09 10:10:26 - no
2024-02-09 10:10:27 - no
2024-02-09 10:10:28 - no
2024-02-09 10:10:29 - no
2024-02-09 10:10:30 - YES
2024-02-09 10:10:31 - no
...

With the ScheduleProvider, which implements the ScheduleProviderInterfaceand is tagged as #[AsSchedule], a new transport is created by the scheduler component with the name scheduler_default.

You can change that name with:

#[AsSchedule('uptoyou')]

Now the name of the transport will be scheduler_uptoyou.

By default, all scheduler messages are executed synchronously. However, you can also redispatch them to another transport using the RedispatchMessage class which is part of the Symfony Scheduler. This is useful if a handler is expected to work for an extended period. Check the documentation for details on how this works.

That is all; I hope you enjoyed.

--

--

Filip Horvat

Senior Software Engineer, Backend PHP Developer, Located at Croatia, Currently working at myzone.com