Symfony Messenger AMQP transport that uses consume instead of get.
the-consoomer is a custom AMQP transport for the Symfony Messenger component. Unlike the default AMQP transport, which relies on the get method for message retrieval, this package uses the consume method to process messages from an AMQP broker. This can result in different performance characteristics and is more suitable for certain messaging patterns.
Requirements: PHP 8.4+ with the amqp extension (PECL amqp) ^2.0 installed.
| Symfony Messenger | Minimum PHP | Minimum amqp extension |
|---|---|---|
| 6.4 / 7.4 / 8.0 | 8.4 | 2.0 |
- Language: PHP
- Framework: Symfony
- License: MIT
- Status: Public, actively maintained
- Custom AMQP transport for Symfony Messenger
- Uses
basic_consumefor push-based message processing - Lower latency and better throughput than polling-based
basic_get
composer require crazy-goat/the-consoomer- Register the transport factory in your Symfony services configuration.
- Use it in your Messenger transport configuration.
services:
CrazyGoat\TheConsoomer\AmqpTransportFactory:
tags:
- { name: 'messenger.transport_factory' }framework:
messenger:
transports:
consoomer:
dsn: 'amqp-consoomer://guest:guest@localhost:5672/%2f/messages?queue=my_queue'amqp-consoomer://<user>:<password>@<host>:<port>/<vhost>/<exchange>/?queue=<queue_name>
Example: amqp-consoomer://guest:guest@localhost:5672/%2f/my_exchange/?queue=test
Reserved query keys: the query string must not use the keys
host,port,user,password,vhostorexchange— these come from the DSN authority/path and are authoritative. A query parameter with one of these keys (e.g.?host=evil&password=secret) throwsInvalidArgumentExceptionat parse time instead of being applied (#207/#360) — a previously-silent connection/credential override was a security hole, so this is an intentional BC break rather than a deprecation.
| Option | Description | Default |
|---|---|---|
queue |
Queue name to consume from | (required) |
max_unacked_messages |
Prefetch count and ack-batch flush threshold | 100 |
batch_size |
Max messages collected per get() call (lower = lower latency, higher = higher throughput) |
1 |
max_body_bytes |
Max raw message body size accepted per message (0 = disabled). Oversized bodies are rejected without being decoded. Non-integer/negative values are rejected at construction | 16777216 (16 MiB) |
timeout |
Consumer timeout in seconds | 0.1 |
heartbeat |
Connection heartbeat interval in seconds (0 = disabled) | 0 |
confirm_timeout |
Publisher confirms timeout in seconds (0 = disabled). See Publish Reliability | 0 |
routing_key |
Consumer-side: binding key used when declaring/binding the queue | '' |
default_publish_routing_key |
Sender-side: default routing key used when publishing messages | '' |
The transport uses separate routing keys for consuming and sending:
- Consumer (Receiver): Uses
routing_keyas the binding key when declaring/binding the queue to an exchange. This determines which messages are routed to the queue. - Sender: Uses
default_publish_routing_keyas the default routing key when publishing messages. This determines how messages are routed through the exchange.
When sending a message, the routing key precedence is:
AmqpStamp::getRoutingKey()— message-specific routing key (highest priority)default_publish_routing_key— configured default for publishing''— empty string (no routing key)
This separation prevents unintended coupling: setting routing_key for consumer binding does not affect how messages are published.
Pitfall — consumer binding vs sender stamp: because the consumer and sender keys are resolved independently, stamping a message with
AmqpStamp::getRoutingKey()(or settingdefault_publish_routing_key) that differs from the queue'srouting_keybinding causes the broker to drop every unroutable message on a direct exchange — publish still reports success. When you publish with a per-messageAmqpStamp, make sure yourrouting_keyDSN option binds the queue with that same key (e.g. publish withnew AmqpStamp('test')and configure...?queue=test&routing_key=test).
The heartbeat feature keeps connections alive and detects dead connections. When enabled, the connection will automatically reconnect if no activity is detected for twice the heartbeat interval.
framework:
messenger:
transports:
consoomer:
dsn: 'amqp-consoomer://guest:guest@localhost:5672/%2f/messages?queue=my_queue&heartbeat=60'With heartbeat enabled:
- Connection is checked before each operation (send, get, ack, reject)
- If stale (elapsed > 2 * heartbeat), automatic reconnect occurs
- Activity is updated after each operation
- In-flight messages delivered before a reconnect are not acknowledged on the new channel — their delivery tag belongs to the dead channel, so ack/reject become no-ops and the broker redelivers them on the next get()
For TLS-encrypted connections use the amqps-consoomer:// scheme (or the legacy amqps://, which emits a deprecation notice). SSL options are passed as DSN query parameters or programmatic options:
| Option | Description | Default |
|---|---|---|
ssl |
Enable TLS (set implicitly by the amqps schemes) |
false |
ssl_cert |
Client certificate PEM file path | (none) |
ssl_key |
Client private key PEM file path | (none) |
ssl_cacert |
CA certificate PEM file path used to verify the broker | (none) |
ssl_verify |
Verify the broker's peer certificate | true |
allow_insecure_verify |
Programmatic opt-in to allow ssl_verify=false (cannot be set from the DSN) |
false |
Pitfall —
ssl_verifywithoutssl_cacert: when verification is on (the default) but no CA certificate is pinned, the connection falls back to the system CA store. On builds where the system has no trusted CAs the handshake fails, or on some builds it verifies against an empty trust set — both silently.AmqpFactory::configureSsl()emits a prominent warning in this case — through the configured logger, or as a PHPE_USER_WARNINGwhen no logger is wired (#351); setssl_cacertexplicitly in production to pin the broker's CA.
Security (#361):
ssl_verify=falsedisables TLS peer-certificate verification, enabling man-in-the-middle / broker impersonation. It is refused unless you set"allow_insecure_verify" => truein the programmatic transport options — it cannot be set from the DSN query string, so a config-file or env-var DSN can never self-authorize the downgrade. Use it only for local development with self-signed certificates.
framework:
messenger:
transports:
consoomer:
dsn: 'amqps-consoomer://guest:guest@rabbit:5671/%2f/messages?queue=my_queue&ssl_cacert=/etc/ssl/certs/rabbit-ca.pem'The transport supports configurable retry logic with exponential backoff, jitter, and circuit breaker.
| Option | Description | Default |
|---|---|---|
retry |
Enable retry mechanism | false |
retry_count |
Maximum number of execution attempts including the first (maxAttempts) |
3 |
retry_delay |
Base delay between retries in microseconds | 100000 |
retry_backoff |
Enable exponential backoff (delay doubles each retry) | false |
retry_max_delay |
Maximum delay cap in microseconds | 30000000 |
retry_jitter |
Enable random jitter (±25%) to prevent thundering herd | true |
retry_circuit_breaker |
Enable circuit breaker pattern | false |
retry_circuit_breaker_threshold |
Consecutive failures before circuit opens | 10 |
retry_circuit_breaker_timeout |
Seconds circuit stays open before half-open probe | 60 |
retry_circuit_breaker_success_threshold |
Successful attempts needed to close circuit | 2 |
framework:
messenger:
transports:
consoomer:
dsn: 'amqp-consoomer://guest:guest@localhost:5672/%2f/messages?queue=my_queue&retry=1&retry_count=3&retry_delay=500000&retry_backoff=1&retry_jitter=1&retry_circuit_breaker=1'With retry enabled:
- Connection and channel failures are retried automatically up to
retry_counttimes (including the first attempt) - Non-AMQP exceptions are not retried
- Resource errors (
AMQPQueueException,AMQPExchangeException) are permanent and not retried; connection/channel errors (AMQPConnectionException,AMQPChannelException) are always retried. For genericAMQPException, reply codes 403/404/406 are treated as permanent. - On exhaustion, a
RetryExhaustedExceptionis thrown with the last failure as previous
The circuit-breaker state is per-process and in-memory only. There is no shared store behind it, so its protection is limited to the lifetime of the current process:
- PHP-FPM / request-scoped senders: a new
CircuitBreakerinstance is created on every request, so failure counters reset each time and a highretry_circuit_breaker_thresholdis effectively never reached. Only failures that recur within a single request count.- Worker fleet (
messenger:consume): each consumer process has its own independent breaker. A broker-wide outage is not coordinated across the fleet — every process must trip (and recover) on its own.- Long-lived single workers: this is where the breaker works as documented — counters persist between operations and the circuit opens after
retry_circuit_breaker_thresholdconsecutive failures.
To size the breaker usefully, keep the effective failure budget in mind: with the defaults
(retry_count=3 attempts per operation) an OPEN circuit is reached after roughly
retry_circuit_breaker_threshold / retry_count consecutive failed operations — by default
10 / 3, i.e. after about 3–4 consecutive failing operations.
No shared-state backend (APCu, Redis, …) is currently provided; do not treat
retry_circuit_breaker_* as fleet-wide or request-spanning protection.
The raw AMQP body is broker-controlled input: anyone who can publish to (or impersonate) a consumed queue decides what bytes reach your serializer. Two guards apply on the receive path (#288):
- Size guard — a body larger than
max_body_bytesis rejected without ever being handed to the serializer, so a single oversized publish cannot push the consumer into memory pressure. Setmax_body_bytes: 0to disable the guard. - Poison-pill containment — when the serializer rejects a body (raises
MessageDecodingFailedException), the offending message is rejected (dropped or dead-lettered per broker policy) and the current batch keeps flowing. If the message cannot even be rejected (broken channel), the decode failure propagates so the problem stays visible instead of looping silently on broker redelivery.
Warning: do not use
PhpSerializerwith untrusted publishers. Symfony'sPhpSerializerrunsunserialize()on the raw body. That is PHP object injection and gadget-chain RCE, not a data error — no size limit changes that. It is a supported option only for first-party publishers you fully trust; with anything untrusted, use a data-format serializer (JSON) and validate on the application side.
Warning: without
confirm_timeout, publishes are fire-and-forget.AMQPExchange::publish()writes to the socket buffer and returns immediately. If the broker is down, the exchange is missing, or the topology was lost after a restart,send()still reports success and the message is silently lost. The retry mechanism is inert on the send path without an error signal.
Enable publisher confirms (confirm_timeout > 0) for reliable publishing:
framework:
messenger:
transports:
consoomer:
dsn: 'amqp-consoomer://guest:guest@localhost:5672/%2f/messages?queue=my_queue&confirm_timeout=5&retry=1'With confirm_timeout enabled:
- The sender calls
confirmSelect()on the channel (once per channel, cached) andwaitForConfirm()after each publish - The broker acknowledges each publish; a 404 (missing exchange) or nack surfaces as an
AMQPException - Combined with
retry=1, transient failures are retried automatically
With retry=1 enabled (regardless of confirms):
- The sender checks
isConnected()before each publish attempt and reconnects if the broker is down, giving the retry wrapper an error signal to act on
With auto_setup=true (default):
- Topology (exchange, queues, bindings) is re-declared after a reconnect —
Sender::ensureConnected()resets the setup flag soauto_setupis not a false promise after a broker restart or topology loss
# All tests
composer test
# Unit tests only
composer test-unit
# E2E tests (requires RabbitMQ)
composer test-e2e-fullE2E tests require RabbitMQ. The test-e2e-full script automatically:
- Starts RabbitMQ via Docker
- Waits for RabbitMQ to be ready
- Runs E2E tests
- Stops RabbitMQ
# Run rector + php-cs-fixer
composer lintContributions are welcome! Please open an issue or submit a pull request.
This project is licensed under the MIT License.
