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()]);
}
}
Почніть зі схеми одним реченням: «дані лежать у повідомленні, логіка в обробнику з #[AsMessageHandler], транспорт описаний у конфігу, і перехід із sync на async це один рядок у routing, а не правка коду». Потім додайте головну відмінність: у Laravel job тримає payload і логіку водночас. Такий порядок одразу показує, що ви розумієте, чому Messenger розділений саме так.