SuperAPI AI Directory

symfony-yoandev-async

Coding & development

View repository

Updated 10-16 08:07

About this skill

Asynchronous work

Tier: on demand — nothing in this skill applies until an operation genuinely leaves the request. The first section decides whether it does; if not, close this file.

Messenger, Mailer and Scheduler. One transport, three rules.

First: does this need to be asynchronous at all?

An operation becomes a message when the user does not need the result. That is the whole test. Not "it is slow": slow work the user is waiting for is still slow when a worker does it, it has just moved somewhere nobody is watching, and now the user gets a success page for something that has not happened yet.

Situation Verdict
Importing 10,000 books from a CSV, mailing the user when it is done Async
Sending the review-published email Async — the next page does not depend on it
Generating a monthly export the user downloads later Async
Recomputing a rating shown on the page rendering next Synchronous
Validating a shelf import so the form can show errors Synchronous
Anything whose result appears in the response Synchronous

The failure mode of getting this wrong is specific: the controller returns 200, the handler throws three hours later, and nobody finds out because nobody reads the failure transport. Async work needs an operational owner — on a project with no alerting and no process supervisor, adding a queue makes the system less reliable. Say so before installing anything.

Messenger is not a command bus

Messenger is used here only for work that leaves the request. A controller calls a service directly — $this->shelfImporter->import($shelf, $file) — and reaches for $this->bus->dispatch(new ImportShelf($id)) only when nobody is waiting.

Dispatching every action through the bus — the CQRS-flavoured "command bus" — is deliberately rejected: the call site stops saying what happens, the stack trace goes through five middlewares, and with a synchronous transport you paid all of that for a method call. Same reasoning as symfony-yoandev-architecture: business consequences are explicit calls, not dispatched events.

Transport: Doctrine

MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 — the queue lives in the application's own database: no extra infrastructure, pending messages visible with a SELECT, covered by the backup and restore procedure that already exists. On PostgreSQL and MySQL 8 the transport reads with FOR UPDATE SKIP LOCKED, so several workers are safe.

auto_setup=0 means messenger_messages is not created at runtime; it comes from a migration, because Doctrine's schema listener adds the table to the generated schema and make:migration picks it up like any other. No DDL in production.

The limit, stated plainly. Past roughly a few thousand messages a day the queue is write load and vacuum pressure on the business database, on top of the constant polling SELECT … FOR UPDATE from every worker. Escalate to Redis (symfony/redis-messenger) then AMQP (symfony/amqp-messenger, for real routing and dead-lettering): a DSN change plus draining the old transport, handlers unchanged. Do not install RabbitMQ for a project sending a hundred emails a day.

The three rules

They prevent the three bugs that actually happen.

1. A message carries identifiers, never entities

#[AsMessage('async')]
final readonly class ImportShelf
{
    public function __construct(public int $shelfId, public string $uploadedFilePath) {}
}

A serialised entity is a snapshot: by the time the worker deserialises it the row has moved on, and writing it back silently reverts whatever happened in between. Doctrine proxies make it worse — what gets serialised is the proxy, not the data. The handler reloads from the repository and works on current state.

#[AsMessage] (Symfony\Component\Messenger\Attribute\AsMessage, 7.2+) puts routing next to the message; on 6.4–7.1 declare it under routing: in config/packages/messenger.yaml instead — same rule, different place.

2. A handler is idempotent

Messenger retries. A handler that charges a card, sends an email or increments a counter must survive running twice on the same message, because it will. Guard on checkable state — if ($shelf->getImportedAt() !== null) { return; } — not on hope.

Symfony 7.3 added DeduplicateMiddleware / DeduplicateStamp (needs symfony/lock). Its own docblock calls it "a best-effort idempotency primitive… not a correctness primitive against a hostile queue producer": a convenience on top of an idempotent handler, never a replacement for one.

3. A handler is a translation layer

Exactly like a controller: unpack the message, load what the service needs, call the service, stop. A rule that lives here is unreachable from the synchronous path and untestable without a transport.

#[AsMessageHandler]
final readonly class ImportShelfHandler
{
    public function __construct(
        private ShelfRepository $shelves,
        private ShelfImporter $importer,
    ) {}

    public function __invoke(ImportShelf $message): void
    {
        $shelf = $this->shelves->find($message->shelfId)
            ?? throw new UnrecoverableMessageHandlingException(
                \sprintf('Shelf "%d" no longer exists.', $message->shelfId),
            );

        $this->importer->import($shelf, $message->uploadedFilePath);
    }
}

That one throw is the retry distinction: retrying will never make a deleted shelf exist, so the message goes straight to the failure transport instead of burning three attempts. UnrecoverableMessageHandlingException for "this will never work"; anything else for "the network was down".

Failure policy

framework:
    messenger:
        failure_transport: failed
        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy: { max_retries: 3, delay: 1000, multiplier: 2, jitter: 0.3 }
            failed: 'doctrine://default?queue_name=failed'

Three attempts at 1s / 2s / 4s, with jitter so a downed third-party API does not receive every retry from every worker in the same millisecond (jitter defaults to 0.1; raising it is free insurance). The message then lands in failed — a Doctrine queue, so persistent, surviving a restart, still there tomorrow morning.

The rule that matters: a message reaching the failure transport must raise an alert. A failure queue nobody reads is a data-loss mechanism with extra steps.

This skill states the rule; symfony-yoandev-observability owns the mechanism and the code is written once, in its references/alerting.md: a listener on WorkerMessageFailedEvent guarded by willRetry(), logging at critical on an alert channel, and — the part that decides whether it is an alert at all — a sink that does not re-enter Messenger. The one fact worth carrying from here: not symfony_mailer. This standard routes SendEmailMessage to async, so an email alert about the broken queue is queued on the broken queue (measured). Do not copy the listener from memory; read that file.

Prioritised queues

One transport per priority, so a 10,000-row import does not sit in front of a signup email:

transports:
    async_high: 'doctrine://default?queue_name=high'
    async:      'doctrine://default?queue_name=default'
    async_low:  'doctrine://default?queue_name=low'
php bin/console messenger:consume async_high async async_low

The worker drains receivers in the order given and only looks at async when async_high is empty. messenger:consume --queues= does not work here — the Doctrine receiver does not implement QueueReceiverInterface, that option belongs to AMQP and Redis. With Doctrine, separate transports are the mechanism.

Testing

Nothing here needs a running worker. The handler is instantiated and called directly — (new ImportShelfHandler($shelves, $importer))(new ImportShelf(12, $path)). The dispatching service gets a createMock(MessageBusInterface::class) with an expects() on dispatch and a self::callback() checking the message values; the ->willReturn(new Envelope($message)) is not optional, because dispatch() is declared : Envelope and a mock returning null fails with a TypeError that reads like a bug in your own code. Functionally, route async to 'in-memory://' under when@test: and read the recorded envelopes with getSent(). Full patterns: references/messenger.md.

Email and Scheduler, in two paragraphs

SendEmailMessage is routed to async by the recipe, and asynchronous is right. Once the project has the prioritised transports above, move it to async_high — a signup confirmation queueing behind a nightly import is the exact problem those transports exist to solve, and it is a one-line change in routing: (references/messenger.md). It stays in routing: rather than becoming #[AsMessage], because you cannot put an attribute on someone else's class. The trap: a TemplatedEmail is rendered by the worker, so its context is serialised — put a Doctrine entity in there and you ship a stale snapshot or a broken proxy. Pass scalars. In tests the assertion is assertQueuedEmailCount(), not assertEmailCount(). The rest: references/mailer.md.

#[AsCronTask('0 3 * * *')] on a service method beats a crontab: versioned with the code it runs, and visible in review. Its condition: it needs a running worker. A crontab runs because cron is always there; a schedule runs only while messenger:consume scheduler_default is up — say that before installing it. Details: references/scheduler.md.

When something does not happen

Symptom Cause
Nothing happens, no error No worker is consuming — messenger:stats shows the pending count
Emails never arrive locally They are queued: symfony console messenger:consume async -vv
Table messenger_messages does not exist auto_setup=0 and the migration was not run
Handler runs old code after a deploy messenger:stop-workers was skipped — see symfony-yoandev-deployment
A message is handled twice Normal. That is what rule 2 is for
Cannot instantiate proxy on deserialise An entity or proxy went into the message or the email context
The worker dies around 128 MB Missing --memory-limit; that is a restart trigger, not a bug
Retries never stop RecoverableMessageHandlingException defaults to forceRetry: true, bypassing max_retries. Pass forceRetry: false — 8.1+ only; on 6.4–8.0 the parameter does not exist, the forced retry is unconditional, and the way out is to throw a different exception (references/messenger.md)
A scheduled task never fires No worker on scheduler_default, or dragonmantank/cron-expression is missing

Reference files

File When to read it
references/messenger.md Configuring transports and retries, running workers in production, the console commands, testing dispatch
references/mailer.md Anything involving email: templates, CSS inlining, the serialisation trap, dev and test setup
references/scheduler.md Recurring tasks, #[AsSchedule], locking, missed runs, replacing a crontab