Усе нижче перевірено на Symfony 7.4 LTS і 8.0, symfony/scheduler (стабільний з 6.4), PHP 8.4. Для cron-виразів потрібен dragonmantank/cron-expression, він не тягнеться автоматично.
Що насправді не так із crontab
crond надійно робить свою роботу десятиліттями, і претензія не до нього. Питання в тому, де живе його конфігурація.
Типовий рядок у проді виглядає так:
*/15 * * * * cd /var/www/app && /usr/bin/php8.4 bin/console app:sync-vacancies --env=prod --no-interaction >> /var/log/app/sync.log 2>&1
Тут закодовано шість речей, які не має знати системний адміністратор: шлях до застосунку, версія PHP, назва команди, оточення, куди писати лог і те, що команда не інтерактивна. Цей рядок не проходить code review, не потрапляє в диф, не має історії змін, і людина, яка змінить назву команди в PHP-коді, не дізнається, що зламала розклад. Тести на нього написати нічим.
Далі накопичується решта. Дрібніше за хвилину крон не вміє, тому «кожні 30 секунд» доводиться робити двома рядками з sleep 30. Захисту від накладання немає, тому кожен другий рядок обростає flock -n. Коли застосунок роз'їжджається на три інстанси, треба або призначити одну машину «тією, де крон», і отримати точку відмови, яку ніхто не моніторить, або розбиратися з розподіленими локами. У Kubernetes це взагалі окремий об'єкт CronJob зі своїм життєвим циклом, який піднімає повний контейнер з фреймворком заради виклику, що триває дві секунди.
Ще у крона немає стану. Машина лежала з 03:00 до 05:00, нічний перерахунок просто не відбувся, а дізнаєтеся ви про це з бізнес-метрики через тиждень.
RecurringMessage і розклад у коді
Scheduler підключається до Messenger як джерело повідомлень. Розклад описується класом-провайдером:
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
#[AsSchedule('default')]
final class MainSchedule implements ScheduleProviderInterface
{
private ?Schedule $schedule = null;
public function __construct(private CacheItemPoolInterface $cache) {}
public function getSchedule(): Schedule
{
return $this->schedule ??= (new Schedule())
->add(
RecurringMessage::every('10 minutes', new SyncVacancies()),
RecurringMessage::cron('0 9 * * *', new SendDailyDigest(), new \DateTimeZone('Europe/Kyiv')),
RecurringMessage::cron('#midnight', new RebuildSearchIndex()),
)
->stateful($this->cache);
}
}
$this->schedule ??= тут потрібен не для економії пам'яті. Метод викликається не один раз, і новий Schedule щоразу тягнув би за собою новий стан і новий лок.
Три форми тригера покривають майже все. every() приймає рядок у форматі strtotime або кількість секунд і відлічує фіксований інтервал; додатково є from і until, щоб задача працювала лише в робочі години:
RecurringMessage::every('30 minutes', new PollExternalApi(), from: '09:00', until: '18:00')
cron() бере звичайний п'ятипольний вираз і, третім аргументом, таймзону. Вказуйте її явно щоразу, коли час має бізнес-сенс: every('24 hours') після переходу на літній час поїде на годину відносно настінного годинника, а cron('0 9 * * *', ..., new \DateTimeZone('Europe/Kyiv')) лишиться о дев'ятій ранку.
Окремо про #midnight. Символ # у виразі замінюється на детерміноване значення, обчислене з самого повідомлення: #midnight перетвориться на конкретну хвилину після опівночі, свою для кожного класу. Це рятує від ситуації, коли двадцять задач стартують рівно о 0 0 * * * і кладуть базу. Той самий ефект вручну дає джиттер:
RecurringMessage::every('1 hour', new WarmUpCache())->withJitter(300)
Коли задача зводиться до виклику методу сервіса, можна обійтися без окремого класу повідомлення й хендлера:
use Symfony\Component\Scheduler\Attribute\AsCronTask;
#[AsCronTask('0 3 * * *', timezone: 'Europe/Kyiv')]
final class PurgeExpiredTokens
{
public function __invoke(): void { /* ... */ }
}
Це зручно для дрібниць, але такі задачі важче тестувати й неможливо перекинути в чергу, тож для всього, що довше секунди, краще залишатися на повноцінних повідомленнях.
Воркер, який тримає розклад
Scheduler реєструє транспорт з іменем scheduler_<назва розкладу>. Споживається він так само, як будь-який інший:
php bin/console messenger:consume scheduler_default async --time-limit=3600
Один процес може обслуговувати кілька транспортів, і порядок аргументів задає пріоритет. Процес має бути під supervisor або systemd з автоперезапуском, а messenger:stop-workers на деплої гарантує, що новий код підхопиться.
Легко пропустити ось що: повідомлення зі scheduler-транспорту обробляється в тому ж воркері, який його отримав. Якщо хендлер крутиться п'ять хвилин, увесь розклад на цей час стоїть. Лікується перекиданням у справжню чергу:
use Symfony\Component\Messenger\Message\RedispatchMessage;
RecurringMessage::cron('0 2 * * *', new RedispatchMessage(new RebuildSearchIndex(), 'async'))
Тепер scheduler-воркер тільки публікує повідомлення в async, а виконують його звичайні консьюмери, з їхньою retry-стратегією та failure-транспортом. Це ж вирішує питання повторів: у scheduler-транспорт не можна нічого надіслати, він працює лише як джерело, тому механізм retry для нього неповноцінний. Або RedispatchMessage, або як мінімум налаштований failure_transport, інакше впалий нічний перерахунок зникне безслідно.
Кількість воркерів теж має значення. Запустите два процеси на scheduler_default і отримаєте два запуски кожної задачі. Лок вирішує це на рівні розкладу:
return $this->schedule ??= (new Schedule())
->add(/* ... */)
->stateful($this->cache)
->lock($this->lockFactory->createLock('scheduler-default'));
Для кількох машин store має бути спільним (Redis, PDO, Zookeeper), flock на локальній ФС тут марний. З локом можна тримати воркери на всіх інстансах: працює той, хто перший узяв лок, решта стоїть у гарячому резерві.
Stateful і пропущені запуски
Без stateful() розклад живе в пам'яті процесу. Воркер стартував о 10:15, отже й точкою відліку стає 10:15, а щоденна дев'ята ранку просто зсунулась. Перезапуск на деплої, OOM-kill, перезавантаження ноди: щоразу розклад починає з нуля і мовчки пропускає все, що мало відбутися, поки його не було.
->stateful($cache) зберігає в кеші час останнього запуску кожного повідомлення. Після старту воркер порівнює збережену дату з поточною і надолужує все, що пропустив. Кеш-пул має бути персистентним і спільним для інстансів: cache.app поверх Redis або Doctrine, а не array і не локальна файлова система в контейнері.
Таке надолужування іноді шкідливе. Якщо воркер лежав добу, а задача щогодинна, на старті ви отримаєте 24 повідомлення поспіль. Для агрегації чи прогріву кешу це безглуздо, потрібен лише останній:
->stateful($this->cache)
->processOnlyLastMissedRun(true)
Для задач, де кожен запуск обробляє свій шматок даних (виставлення рахунків, нарахування), навпаки потрібне повне надолужування, і тоді хендлер має бути ідемпотентним, бо ніхто не гарантує, що повідомлення не обробиться двічі.
Ідентифікатор стану обчислюється з тригера й самого повідомлення. Змінили вираз або поля повідомлення, і для планувальника це вже нова задача з чистим станом, а старий запис у кеші протухне сам. Відповідно, після зміни розкладу перший запуск може відбутися не там, де ви очікували.
Тестування розкладу
Розклад у коді нарешті можна перевірити тестом, не чекаючи ночі.
Провайдер тут звичайний сервіс, а тригери - чисті об'єкти з методом getNextRunDate():
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
final class MainScheduleTest extends TestCase
{
public function testNextRunDates(): void
{
$schedule = (new MainSchedule(new ArrayAdapter()))->getSchedule();
$now = new \DateTimeImmutable('2026-09-27 10:05:00', new \DateTimeZone('Europe/Kyiv'));
$dates = [];
foreach ($schedule->getRecurringMessages() as $recurring) {
$dates[] = $recurring->getTrigger()->getNextRunDate($now)?->format('c');
}
self::assertSame([
'2026-09-27T10:10:00+03:00',
'2026-09-28T09:00:00+03:00',
// ...
], $dates);
}
}
Такий тест ловить три найчастіші помилки: зайву зірочку у виразі, забуту таймзону і задачу, яку хтось видалив разом із рефакторингом. Він падає в CI, а не о третій ночі.
Хендлери тестуються окремо і без планувальника взагалі: це звичайні класи з __invoke(). Якщо в них є логіка «за останні 24 години», підставте MockClock з symfony/clock замість new \DateTimeImmutable(), і час перестане бути джерелом плаваючих тестів.
Для перевірки живої конфігурації є консольна команда:
php bin/console debug:scheduler
Вона показує зареєстровані розклади, їхні повідомлення й дату наступного запуску для кожного. Перший крок, коли задача «не працює на проді»: подивитися, чи вона взагалі в списку, і чи воркер на scheduler_default живий.
Де cron усе ще доречний
Scheduler не скасовує crond, він забирає в нього задачі застосунку. За кроном лишається те, що не є частиною PHP-коду: ротація логів, бекапи бази, оновлення сертифікатів, перезапуск самого воркера за розкладом, якщо ви не довіряєте --time-limit. Один рядок у crontab замість дванадцяти, і той не згадує назв ваших команд.
Ціна цього відома заздалегідь. Замість надійного системного демона ви отримуєте довгоживучий PHP-процес, який треба перезапускати на деплої, моніторити на живість і тримати під оком за пам'яттю. Якщо у вас три задачі раз на добу і немає Messenger у проєкті, крон чесніший. Якщо ж задач десяток, у них таймзони, вікна виконання, кілька інстансів застосунку і вимога не губити пропущені запуски, то все це вже написано в компоненті й перевіряється тестами.