idct/symfony-nats-messenger
A Symfony Messenger transport for NATS that lets PHP applications publish and consume messages over NATS subjects with native Symfony integration, configuration, and worker support for event-driven architectures.
1. Messaging backbone selection (build vs. buy vs. keep) This package is the deciding artifact in a "NATS JetStream vs. RabbitMQ vs. Redis Streams vs. SQS/EventBridge" evaluation for a PHP estate. It makes NATS a first-class, configurable option for Symfony Messenger rather than a bespoke integration project — DSN-driven, bundle-registered, no glue code. Concrete decisions it unblocks:
stream_storage: file, stream_replicas, stream_max_bytes/messages/max_age give you retention policy as configuration, so capacity planning becomes a config review instead of an infra project.scheduled_messages + Symfony DelayStamp → NATS schedule headers removes a cron/Lambda/Redis-ZSET component from the architecture diagram.2. Delivery-guarantee and idempotency posture This is the highest-leverage PM decision here, because it changes product-visible behaviour:
retry_handler: symfony (TERM, default) keeps retry/DLQ logic inside Symfony Messenger → your existing retry strategy, failure transport, and messenger:failed workflow stay authoritative. Choose this to preserve current on-call tooling.retry_handler: nats (NAK) pushes redelivery to the broker → lower app complexity, but your DLQ and "poison message" story now lives in NATS. This is an operations ownership decision, not a library setting; make it explicitly.deduplicate + DeduplicationIdStamp (Nats-Msg-Id) gives broker-side duplicate suppression in a defined window. This is the lever for "do we need consumer-side idempotency keys?" — often the answer becomes "publisher-side dedupe ID plus a cheap idempotency check," which is materially less work than a full exactly-once design.409 Server Shutdown or 409 Leadership Change on a pull is an empty batch, not a missing-consumer error. That turns the routine "broker node restarted / JetStream leader moved" event from a worker crash into a no-op poll — a direct reduction in pages, and a reason the HA story is credible rather than aspirational. Note the remaining edge: an ack_sync acknowledgement that lands inside an outage still fails after request_timeout.3. Operational footprint and HA decisions
stream_replicas lets you decide "single node is acceptable for this workload" per stream instead of per cluster — useful for tiering: replicas=1 for analytics/low-value events, replicas=3 for order events.stream_storage: memory vs file is a per-stream durability decision you can now make in a PR rather than a platform ticket.reconnect: true (default false) changes the failure mode of a worker from "process exits on connection loss" to "process polls through the outage and resumes." max_reconnect_attempts (default: client default of 10, ≈109 s against a refusing server) is now the knob that expresses "how long a deploy or broker restart may last before we accept worker restarts." That is a product availability setting you can put in a config review: it is the difference between a rolling NATS upgrade being invisible to the queue and being an incident. With auto_setup, provisioning is re-verified after reconnect and a publish that hits a stream-less server gets one setup() plus one replay of the same frame — worth deciding whether you want that self-healing or prefer the failure.ack_wait — i.e. expect duplicate deliveries around long outages. That is consistent with the at-least-once posture, but it belongs in the runbook.4. Throughput/latency trade-off, expressed as config
batching + max_batch_timeout and consumer_strategies (shared vs. independent) let you tune per-transport. Practical PM framing: batch the analytics and email pipelines for cost; keep batching=1 for user-facing, latency-sensitive paths. Because Messenger supports multiple transports, you can run mixed strategies in one app and measure. The reconnect bounds are expressed in the same currency — request_timeout per request, max_batch_timeout + 1 s per pull — so latency budgets and outage budgets are tuned together rather than by folklore.
5. Migration and coexistence Multiple transports in Symfony Messenger means you can stand NATS up beside the incumbent broker and canary one queue, one consumer group at a time — reversible by removing one DSN line. This converts a "big bang broker migration" into a roadmap of small, individually shippable slices. Reconnect mode improves this specifically: during the overlap period a flaky incumbent or a NATS node bounce no longer forces a worker restart that muddies your comparison data.
6. TLS/mTLS and auth as a compliance gate, not a feature
nats-jetstream+tls://, client certificates, JWT/NKey/token auth mean security review isn't a blocker. If you're in a regulated environment, the presence of mTLS + per-stream retention config is often what turns "no" into "yes with conditions." 5.7's PSR-3 wiring (connection lifecycle at info, errors at error, under the messenger channel) also means connection-level events land in the existing audit/log pipeline instead of a library-specific sink — one less exemption to argue for.
7. Test/quality posture affecting risk appetite The 5.7 release raises this materially: 669 unit tests, 99.78% statement coverage, and mutation testing at 96.9% (1008/1040 mutants killed) with the 32 survivors individually documented. Mutation score is the signal a coverage gate can't give you — it says the assertions bite on the risky branches. The reconnect work itself ships with a dedicated test that drives the real client over a scripted connection (silent, failed write, server-side close, held and refused dials) plus Behat scenarios that restart the server under a live worker and under a sender. For a package of this size that is a disproportionately strong engineering signal, and it is now a specific signal about the newly added code path rather than a general one. That justifies a pilot rather than a blanket rejection on popularity.
8. Where it does not help — a decision you should make early
This is a Symfony Messenger transport, not a Laravel queue driver. If the product is genuinely Laravel-first, adopting it means adopting symfony/messenger as your bus, or writing a bridge — you would forfeit Laravel's queue ergonomics (Horizon, failed_jobs, queue:work semantics, job middleware). Make that call explicitly: "we are standardising on Symfony Messenger inside Laravel" is a legitimate architectural decision, but it is a decision, not an install. Related: observability is now logging-complete but still not metrics-complete — 5.7 hands the client your logger, which is not the same as tracing or dashboards.
Adopt (pilot or direct) when:
reconnect: true plus the 409-as-empty-batch behaviour is the feature that makes this a viable production posture rather than a demo one.psr/log ^3.0.)Look elsewhere (or plan for significant internal work) when:
^7.2 / ^8.0 constraint forces a framework upgrade — price that in; it will dominate the timeline.ack_wait) — still correct, still not exactly-once. If ordering is per-entity, design partition keys or pick a different tool.PhpSerializer fallback across rolling deploys. The default is igbinary with PHP-serialize fallback; serialized payload compatibility between old and new app versions during a deploy needs an explicit plan (versioned message contracts), or you'll ship a deploy-window incident.ideaconnect/php-nats-jetstream-client#244). If that latency is unacceptable on your critical path, use ack_sync and accept request_timeout as the bound, or look elsewhere.Practical pilot gate before committing roadmap: one non-critical queue, one sprint, success criteria = publish validation surfaces failures; NAK/TERM semantics behave as documented; worker memory is flat over 24h; one deliberately poisoned message reaches a DLQ via the chosen retry handler; and a docker stop / rolling restart of the broker with reconnect: true leaves the worker alive and consuming, with max_reconnect_attempts sized against your longest realistic outage (the default 10 attempts ≈109 s). If those five pass, adopt; if worker memory or reconnect behaviour is shaky at your traffic shape, stop and reconsider.
To executives (one paragraph): "We can run our background job processing on NATS JetStream using a small MIT-licensed, well-tested Symfony transport instead of standing up another broker cluster. It gives us durable messaging, delayed jobs, HA and mTLS through configuration, which removes a component from our architecture and a scheduler from our roadmap. The latest release closes the operational objection we raised: workers now ride out a broker restart or rolling upgrade instead of exiting, and connection events land in our existing logging. The commercial risk remains that the library is small and single-maintainer — mitigated by MIT licensing (we can fork and own it), a test suite that includes mutation testing at ~97% and scripted-failure tests of the exact reconnect path, and a two-week pilot on one non-critical queue before any commitment. Expect the real cost to be a Symfony/PHP version alignment and metrics/tracing instrumentation, not the integration itself."
To engineering (the honest version):
composer require, Flex registers the factory and igbinary serializer, and you configure it entirely by DSN and options. You'll be productive in an afternoon."ping_after_idle, explicit connection_timeout / request_timeout, and --keepalive in-progress acks queued from the signal handler. Those are the details you'd otherwise discover in production."reconnect is off by default and there is nothing to change on upgrade. Two behaviour changes apply either way — a 409 Server Shutdown / 409 Leadership Change pull now returns an empty batch instead of erroring (and auto_setup stops re-provisioning against a dying server), and if you have a logger service the bundle hands it to the NATS client. New direct dependency: psr/log ^3.0."reconnect: true on deliberately and size max_reconnect_attempts against your real outage budget. Per-operation bounds are documented: request_timeout for a request, max_batch_timeout + 1 s for a pull. Know the trade: a recovery that gives up drops buffered acknowledgements (logged at error level) and the messages come back after ack_wait — so a long outage buys you duplicates, not loss. ack_sync inside an outage still fails at request_timeout; that's the operation that doesn't get better."retry_handler deliberately: symfony/TERM keeps retries and the failure transport inside Messenger; nats/NAK moves them to the broker. Don't leave it at default by accident — it defines who owns the DLQ."docs/TESTS.md, PHPUnit on 8.2–8.5, a reconnect test that drives the real client over a scripted silent/failing/closing/refusing connection, and Behat features against a real NATS for NAK, TERM, batching, delays, TLS, mTLS, and server restarts under a live worker and sender. That's the test matrix you'd have asked for, aimed at the new code."Framing for the buy-vs-build conversation: building this transport internally is roughly "DSN parsing, stream lifecycle, consumer modes, retry semantics, TLS/mTLS, delayed messages, dedupe headers, reconnect-with-bounded-recovery semantics, plus the test matrix above." The reconnect work alone — per-operation budgets, re-verifying setup after recovery, replaying a publish exactly once, and deciding what to do with buffered acks — is the kind of thing that takes a quarter to get subtly wrong. Buying it (at the cost of a fork risk) is clearly the better trade — provided the pilot clears the five gates and someone is named as the internal owner of the fork.
How can I help you explore Laravel packages today?