<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
SYMFONY · MIDDLE ЧАСТО ПИТАЮТЬ

Що дає Symfony Messenger: повідомлення, обробники, транспорти, синхронно чи асинхронно?

Messenger розділяє три речі. Повідомлення тримає лише дані, логіка живе в обробнику з #[AsMessageHandler] і методом __invoke(), а транспорт описується DSN у конфігу. Де саме обробник виконається, вирішує секція routing: без маршруту він спрацює синхронно в тому ж запиті, з маршрутом на doctrine/amqp/redis його підхопить воркер messenger:consume, а в тестах той самий код ганяють через sync:// або in-memory://.

Ми хочемо відправляти листи у фоні. Що саме треба додати в Symfony і де з'являється worker?
Задиспатчили повідомлення, воно виконалось прямо в запиті, черга порожня. Чому?
Чим повідомлення Messenger відрізняється від job у Laravel?
Як у тесті перевірити, що контролер поставив повідомлення в чергу, не піднімаючи RabbitMQ?
Messenger черги AsMessageHandler транспорти тестування

Messenger (Symfony 6.4/7.x, пакет symfony/messenger) тримається на трьох ролях, які спеціально розведені по різних класах. У повідомленні лежать тільки дані: ніякого інтерфейсу, ніяких сервісів у конструкторі, бажано readonly і скалярні поля. Обробником стає сервіс з атрибутом #[AsMessageHandler] і методом __invoke(), і тип першого аргументу цього методу визначає, яке повідомлення він ловить; реєструвати нічого в YAML не потрібно, бо autoconfigure чіпляє тег messenger.message_handler сам. Третя роль - транспорт, тобто DSN, за яким повідомлення серіалізується й кладеться в чергу. Атрибут з'явився в 5.4, а MessageHandlerInterface, який використовували до нього, у Symfony 7.0 видалений, тому код із цим інтерфейсом видає старий проєкт.

Найважливіше для співбесіди: місце виконання не записане в коді. Якщо для класу повідомлення немає рядка в framework.messenger.routing, шина знаходить обробник і викликає його просто зараз, у тому ж процесі: dispatch() повернеться вже після того, як обробник відпрацював. Додайте 'App\Message\SendInvoice': async, і той самий dispatch() лише складе повідомлення в транспорт, а виконає його окремий процес php bin/console messenger:consume async. У класах не змінилось нічого, і це навмисно: той самий обробник працює синхронно в тесті й асинхронно в проді. У routing допускаються інтерфейси, батьківські класи й '*' як fallback, а один клас можна відправити на кілька транспортів одразу.

Повертається з dispatch() не результат, а Envelope: саме повідомлення плюс набір stamps. Stamps тримають метадані маршруту. DelayStamp відкладає доставку (на AMQP через delayed exchange, на doctrine і redis через поле доступності), TransportNamesStamp перевизначає транспорт для одного виклику, DispatchAfterCurrentBusStamp затримує відправку, поки не закінчиться поточний обробник, HandledStamp приносить значення, яке повернув обробник. Останнє працює лише для синхронної шини, і саме з нього зроблений HandleTrait для query-шини. Коли повідомлення поїхало в транспорт, повернути з нього значення вже неможливо, і результат доводиться писати в базу або віддавати другим повідомленням.

Для тестів Symfony дає два службових транспорти, і вони відповідають на різні питання. sync:// виконує обробник одразу при диспатчі й підходить, коли тест перевіряє наслідок: натиснули «підтвердити», в кінці тесту статус уже змінено. in-memory:// не виконує нічого, а накопичує повідомлення, і тест дістає транспорт з контейнера, щоб перевірити getSent(). Для in-memory:// варто завжди ставити ?serialize=true: без цього прапорця повідомлення лежить у масиві живим об'єктом, і тест пропустить DTO з сутністю або замиканням усередині, яке на проді зламає серіалізацію. Обробник при цьому лишається звичайним класом, тому для його логіки пишеться unit-тест із прямим викликом __invoke().

Порівняння з Laravel зводиться до межі між даними й логікою. Laravel-job зліплений в один клас: реалізує ShouldQueue, тримає і payload, і handle(), і їде в чергу через нативний serialize() разом із SerializesModels, що підмінює моделі ключами. Звідси випливає решта: ShouldQueue на класі (або dispatchSync() на виклику) задає синхронність у коді, обробник у job рівно один, а налаштування $tries, backoff(), retryUntil() живуть у самому job. Messenger переносить усі ці рішення в конфігурацію транспорту й дозволяє кілька обробників на повідомлення, зате не має вбудованих ланцюжків і батчів у стилі Bus::batch(), а BatchHandlerInterface групує повідомлення вже на боці споживача. І ще одне, чого в Laravel-чергах немає за задумом: синхронна шина Messenger із doctrine_transaction і validation у middleware працює як готовий command bus, яким користуються й без жодного асинхрону.

// config/packages/messenger.yaml вирішує, де виконається обробник:
//   transports:
//     async: '%env(MESSENGER_TRANSPORT_DSN)%'   # doctrine://default
//     failed: 'doctrine://default?queue_name=failed'
//   failure_transport: failed
//   routing:
//     'App\Message\SendInvoice': async          # приберіть рядок - і стане синхронно

// Повідомлення: лише дані, жодної логіки й жодної сутності всередині
final readonly class SendInvoice
{
    public function __construct(
        public int $orderId,          // id, а не сам Order: сутність не виживає серіалізацію
        public string $locale = 'uk',
    ) {}
}

// Обробник: звичайний сервіс, залежності через конструктор, у транспорт не їде
#[AsMessageHandler]
final readonly class SendInvoiceHandler
{
    public function __construct(
        private OrderRepository $orders,
        private InvoiceMailer $mailer,
    ) {}

    public function __invoke(SendInvoice $message): void   // тип аргументу = прив'язка
    {
        $order = $this->orders->find($message->orderId)
            ?? throw new UnrecoverableMessageHandlingException('Замовлення видалили');

        $this->mailer->send($order, $message->locale);
    }
}

// Відправка з контролера
final class OrderController extends AbstractController
{
    public function confirm(Order $order, MessageBusInterface $bus, EntityManagerInterface $em): Response
    {
        $order->confirm();
        $em->flush();                                      // спершу коміт, потім диспатч

        $bus->dispatch(new SendInvoice($order->getId()), [new DelayStamp(5_000)]);

        return $this->redirectToRoute('order_show', ['id' => $order->getId()]);
    }
}
Що повідомлення залишається звичайним PHP-класом без інтерфейсу й без логіки, а обробник живе окремим сервісом, і завдяки цьому на одне повідомлення можна повісити два обробники, а sync замінити на async без правок коду.
Що #[AsMessageHandler] (з 5.4) замінив MessageHandlerInterface, якого в Symfony 7 уже немає, і що прив'язка до повідомлення йде через тип аргументу __invoke().
Що без рядка в framework.messenger.routing повідомлення обробляється синхронно в тому ж процесі: така поведінка за замовчуванням, а не наслідок недоналаштованого конфігу.
Що dispatch() повертає Envelope, а не результат обробника, і що з асинхронного повідомлення значення назад не дістати.
Що в тестах використовують sync:// (виконати обробник одразу) або in-memory:// (нічого не виконувати й перевірити getSent()), і що in-memory://?serialize=true ловить несеріалізовні повідомлення.
Класти в повідомлення Doctrine-сутність замість її id: при PhpSerializer у транспорт поїде відірваний від EntityManager об'єкт, і воркер або впаде, або збереже стару версію.
Диспатчити async-повідомлення до flush(): воркер читає БД швидше, ніж транзакція комітиться, і не знаходить запис. Рятує DispatchAfterCurrentBusStamp або диспатч після flush.
Чекати результат від обробника: $bus->dispatch() віддає Envelope, і $envelope->last(HandledStamp::class) має значення лише для синхронних викликів.
Забути запустити messenger:consume або не перезапустити воркер після деплою: старий процес тримає в пам'яті старий код, поки не отримає messenger:stop-workers.
Писати бізнес-логіку в тому самому класі, що й повідомлення, за звичкою з Laravel: сенс транспорту зникає, бо серіалізується клас разом із залежностями.
Вважати, що sync:// у тестах еквівалентний продакшену: він не серіалізує повідомлення й не перевіряє retry, тому ламані payload-и видно лише на in-memory://?serialize=true.
ПОРАДА

Почніть зі схеми одним реченням: «дані лежать у повідомленні, логіка в обробнику з #[AsMessageHandler], транспорт описаний у конфігу, і перехід із sync на async це один рядок у routing, а не правка коду». Потім додайте головну відмінність: у Laravel job тримає payload і логіку водночас. Такий порядок одразу показує, що ви розумієте, чому Messenger розділений саме так.

оновлено 3 жовтня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Прив'язка обробника йде через тип аргументу __invoke(), а місце виконання - через routing. Маркерних інтерфейсів Messenger не вимагає (MessageHandlerInterface видалений у 7.0), воркер запускається окремо командою messenger:consume, а dispatch() віддає Envelope - результат доступний лише для синхронної шини через HandledStamp.