<?php
namespace App\Event;
use Symfony\Contracts\EventDispatcher\Event;
final class UserRegisteredEvent extends Event
{
public function __construct(
private readonly int $userId,
private readonly string $email,
) {
}
public function getUserId(): int
{
return $this->userId;
}
public function getEmail(): string
{
return $this->email;
}
}
<?php
namespace App\EventSubscriber;
use App\Event\UserRegisteredEvent;
use App\Message\SendWelcomeEmail;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Messenger\MessageBusInterface;
final class SendWelcomeEmailSubscriber implements EventSubscriberInterface
{
public function __construct(private readonly MessageBusInterface $bus)
{
}
public static function getSubscribedEvents(): array
{
return [
UserRegisteredEvent::class => 'onUserRegistered',
];
}
public function onUserRegistered(UserRegisteredEvent $event): void
{
// Cheap dispatch: the actual SMTP call happens on the worker.
$this->bus->dispatch(new SendWelcomeEmail(
$event->getUserId(),
$event->getEmail(),
));
}
}
<?php
namespace App\Message;
final class SendWelcomeEmail
{
public function __construct(
public readonly int $userId,
public readonly string $email,
) {
}
}
namespace App\MessageHandler;
use App\Message\SendWelcomeEmail;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mime\Address;
#[AsMessageHandler]
final class SendWelcomeEmailHandler
{
public function __construct(private readonly MailerInterface $mailer)
{
}
public function __invoke(SendWelcomeEmail $message): void
{
$email = (new TemplatedEmail())
->from(new Address('hello@example.com', 'Acme'))
->to($message->email)
->subject('Welcome to Acme!')
->htmlTemplate('emails/welcome.html.twig')
->context(['userId' => $message->userId]);
$this->mailer->send($email);
}
}
framework:
messenger:
failure_transport: failed
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 2000
multiplier: 2
failed:
dsn: 'doctrine://default?queue_name=failed'
routing:
App\Message\SendWelcomeEmail: async
This snippet shows the idiomatic Symfony way to decouple a side effect — sending a welcome email — from the request that triggers it. Instead of blocking the HTTP response while SMTP is contacted, the work is dispatched as a message and handled by a worker consuming a transport. That keeps the controller fast and makes the email delivery retryable and observable.
The flow starts in UserRegisteredEvent, a plain immutable event carrying the freshly persisted user's id and email. It is not a Symfony framework class; it is a domain event the application dispatches after registration succeeds. Passing only the id and email (rather than the whole entity) keeps the event small and avoids serialization problems later when the same data becomes a queued message.
SendWelcomeEmailSubscriber implements EventSubscriberInterface and listens for that event via getSubscribedEvents. Crucially, the subscriber does almost nothing itself: it wraps the payload in a SendWelcomeEmail message and hands it to the MessageBusInterface. This is the key architectural choice — the subscriber runs synchronously inside the request, but dispatching to the bus is cheap, so the expensive SMTP call is pushed out of the request cycle. If the async transport is configured, dispatch simply enqueues the envelope and returns.
SendWelcomeEmail message and handler holds both the message DTO and its #[AsMessageHandler] handler in one file for readability. The handler receives the message on the worker, builds a TemplatedEmail pointing at a Twig template, and sends it through the MailerInterface. Because handler failures are retried by Messenger according to the transport's retry strategy, transient SMTP errors do not lose the email.
The messenger.yaml config ties it together: SendWelcomeEmail is routed to the async transport, so it is serialized to a real queue (Doctrine, Redis, or AMQP) instead of being handled inline. A failed transport captures messages that exhaust their retries so they can be inspected with messenger:failed:show and replayed. The trade-off of this pattern is added infrastructure — a running messenger:consume worker — in exchange for faster responses, isolation of failures, and natural retries. A common pitfall is forgetting the worker or the routing entry, which silently makes everything run synchronously again.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
class CreateTopSellersMv < ActiveRecord::Migration[7.0]
def up
execute <<~SQL
CREATE MATERIALIZED VIEW top_sellers AS
SELECT p.id AS product_id,
p.name AS product_name,
Cache-Friendly “Top N” with Materialized View Refresh
class CreateDeadJobs < ActiveRecord::Migration[7.1]
def change
create_table :dead_jobs do |t|
t.string :jid, null: false
t.string :queue, null: false
t.string :klass, null: false
Background Job Dead Letter Queue (DLQ) Table
Share this code
Here's the card — post it anywhere.