php yaml 111 lines · 4 tabs

Symfony: Send a Welcome Email Asynchronously with Messenger and an Event Subscriber

Shared by codesnips Jul 2026
4 tabs
<?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;
    }
}
4 files · php, yaml Explain with highlit

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

typescript
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

typescript reliability retry
by codesnips 2 tabs
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
javascript
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
  const success = true;

  setTimeout(() => {
    if (success) {

Promises and async/await patterns for asynchronous JavaScript

javascript promises async-await
by Alex Chang 1 tab
typescript
export type Settled<R> =
  | { status: 'fulfilled'; value: R }
  | { status: 'rejected'; reason: unknown };

export interface ConcurrencyOptions {
  limit: number;

Simple concurrency limiter for batch operations

node concurrency async
by codesnips 2 tabs
ruby
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

rails postgres performance
by codesnips 4 tabs
ruby
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

rails reliability background-jobs
by codesnips 4 tabs

Share this code

Here's the card — post it anywhere.

Symfony: Send a Welcome Email Asynchronously with Messenger and an Event Subscriber — share card
Link copied