- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You SSH into the box to check why the nightly report didn't send. You run crontab -l. There's a wall of lines, half of them commented out, one pointing at a bin/console command that got renamed six months ago. Nobody knows who added the 3am entry. It's not in git. It's not in the deploy pipeline. It exists only on this one server, and when the box gets recycled by the autoscaler, it's gone.
That's the problem the Symfony Scheduler component solves. Your recurring tasks stop being infrastructure trivia on a single host and become versioned PHP that ships with your code, gets code-reviewed, and can be unit tested. The Scheduler landed as stable in Symfony 6.4 and has grown a lot since. Here's how it works and the one gotcha that will bite you in production.
The shape: a schedule provider
The core idea: a class that describes what runs and when. You mark it with #[AsSchedule] and implement ScheduleProviderInterface.
<?php
// src/Scheduler/MainSchedule.php
namespace App\Scheduler;
use App\Message\SendDailyReport;
use App\Message\PurgeExpiredTokens;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
#[AsSchedule('main')]
final class MainSchedule implements ScheduleProviderInterface
{
private ?Schedule $schedule = null;
public function getSchedule(): Schedule
{
return $this->schedule ??= (new Schedule())->add(
RecurringMessage::every(
'1 hour',
new PurgeExpiredTokens(),
),
RecurringMessage::cron(
'0 6 * * *',
new SendDailyReport(),
),
);
}
}
Two triggers, two styles. every() takes a human interval ('10 minutes', '1 hour', '1 day'). cron() takes a standard cron expression, so you keep the vocabulary you already know. The ??= cache matters: getSchedule() can be called more than once, and you want the same Schedule instance each time.
The messages themselves are plain DTOs. No base class, no interface.
<?php
// src/Message/SendDailyReport.php
namespace App\Message;
final class SendDailyReport
{
public function __construct(
public readonly string $segment = 'all',
) {
}
}
Cron needs a package, and can hash for you
RecurringMessage::cron() depends on dragonmantank/cron-expression. Install it or the trigger throws:
composer require dragonmantank/cron-expression
Once it's there, you get a feature worth knowing about: hashed cron expressions. If every service in your fleet fires its cleanup job at 0 0 * * *, midnight becomes a stampede. A hashed expression spreads the load by deriving the exact minute from a hash of the message.
// runs once a day, but at a stable pseudo-random
// minute derived from the message, not on the
// midnight boundary everyone else picked
RecurringMessage::cron('#daily', new PurgeExpiredTokens());
// or a whole hashed expression
RecurringMessage::cron('# # * * *', new SendDailyReport());
The minute stays stable for a given message, so you get spread-out load without random drift between runs.
Messenger is the engine underneath
The Scheduler doesn't run anything on its own. It's a Messenger transport that generates messages when a trigger is due. That's the design that makes it worth adopting: your scheduled message goes through the same bus, the same middleware, the same retry strategy, and the same failure transport as every other message in your app.
So the handler is an ordinary Messenger handler:
<?php
// src/MessageHandler/SendDailyReportHandler.php
namespace App\MessageHandler;
use App\Message\SendDailyReport;
use App\Report\ReportMailer;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class SendDailyReportHandler
{
public function __construct(
private readonly ReportMailer $mailer,
) {
}
public function __invoke(SendDailyReport $message): void
{
$this->mailer->sendFor($message->segment);
}
}
You run the schedule by consuming its transport. The transport name is scheduler_ plus the name you gave #[AsSchedule]:
php bin/console messenger:consume scheduler_main -vv
That's the process that ticks. It stays up, checks triggers, and dispatches due messages into your handlers. In production it goes under Supervisor or systemd like any other Messenger worker. No line in a crontab anywhere.
Skip the message for one-off methods
When you have a service method that runs on a schedule and doesn't need a message and handler, two attributes cut the ceremony. They target the method directly.
<?php
// src/Maintenance/TokenCleaner.php
namespace App\Maintenance;
use Symfony\Component\Scheduler\Attribute\AsCronTask;
use Symfony\Component\Scheduler\Attribute\AsPeriodicTask;
final class TokenCleaner
{
#[AsCronTask('0 3 * * *')]
public function purgeExpired(): void
{
// runs every day at 03:00
}
#[AsPeriodicTask(frequency: '5 minutes')]
public function refreshCache(): void
{
// runs every five minutes
}
}
These register onto the default schedule, so you consume them with messenger:consume scheduler_default. Good for maintenance chores. When the task carries a payload or belongs in your domain, prefer the message-and-handler form.
Schedules you can unit test
This is the payoff a crontab can never give you. The schedule is an object, so you can assert on it without booting a worker or waiting for the clock.
<?php
// tests/Scheduler/MainScheduleTest.php
namespace App\Tests\Scheduler;
use App\Message\SendDailyReport;
use App\Scheduler\MainSchedule;
use PHPUnit\Framework\TestCase;
final class MainScheduleTest extends TestCase
{
public function testDailyReportFiresAtSix(): void
{
$schedule = (new MainSchedule())->getSchedule();
$messages = $schedule->getRecurringMessages();
$report = null;
foreach ($messages as $recurring) {
if ($recurring->getMessage() instanceof SendDailyReport) {
$report = $recurring;
}
}
self::assertNotNull($report);
$from = new \DateTimeImmutable('2026-07-02 05:00');
$next = $report->getTrigger()->getNextRunDate($from);
self::assertSame(
'2026-07-02 06:00',
$next->format('Y-m-d H:i'),
);
}
}
You can now catch "someone changed the cron and nobody noticed" in CI instead of in a support ticket. The trigger's getNextRunDate() is pure and deterministic, so timing logic becomes a normal test, not a thing you verify by staring at production logs.
The single-runner gotcha
Here's the trap. The Scheduler decides what's due inside the consumer process. If you run two messenger:consume scheduler_main workers for redundancy, each one keeps its own view of the clock. Both see the 6am trigger fire. Your daily report goes out twice. Under an autoscaler that spins up three workers, three times.
Scaling Messenger workers horizontally is normal and correct for throughput. The scheduler transport is the exception. Trigger generation has to run on exactly one process.
The fix is a lock on the schedule. Install the Lock component and hand the schedule a lock so only the holder generates messages:
composer require symfony/lock
public function getSchedule(): Schedule
{
return $this->schedule ??= (new Schedule())
->add(
RecurringMessage::cron(
'0 6 * * *',
new SendDailyReport(),
),
)
->lock(
$this->lockFactory->createLock('scheduler-main'),
);
}
Inject a LockFactory backed by a shared store (Redis, a database, anything not local to one box). Now ten workers can be up and only the lock holder ticks. The rest wait. Redundancy without duplicates.
The related concern is a worker that was down when a trigger was due. By default a missed trigger is missed. Add ->stateful() with a cache pool and the schedule persists its last run, so a worker that comes back up can catch a run it slept through instead of silently skipping it.
->stateful($this->cachePool)
->lock($this->lockFactory->createLock('scheduler-main'));
Use a shared cache pool here too, not cache.app pointed at a per-host filesystem, or each box gets its own idea of what already ran.
Why this beats the crontab
The crontab was never wrong, it was just in the wrong place. It lives on a host, outside version control, outside review, outside your test suite, and it invokes your app through a fresh CLI boot every time. The Scheduler pulls that concern into the application: triggers are code, they route through the bus you already trust, and next-run logic is something PHPUnit can assert on. When you deploy, the schedule deploys with it. When you roll back, it rolls back too.
The one discipline it asks for is the single-runner rule. Get the lock in place before you scale the consumer, and the rest is ordinary Messenger.
Scheduling is a delivery concern, not a domain concern, and the Scheduler keeps it that way: the message stays a clean DTO your domain can own, while the trigger, the lock, and the transport sit at the framework edge where they belong. That separation, keeping the "when" and the "how it runs" out of your business logic, is the same boundary work that keeps an application maintainable as it grows. It's the whole argument of Decoupled PHP.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)