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.
Architecture fit. This is a Symfony Messenger transport, not a Laravel queue driver. It implements TransportInterface, MessageCountAwareInterface and SetupableTransportInterface, and ships IDCTNatsMessengerBundle for Symfony Flex auto-registration. In a Laravel application that bundle is inert — Flex does not exist, so the transport factory and the igbinary serializer must be registered by hand in a service provider, and a MessageBus (with SendersLocator and HandlersLocator) must be constructed outside Laravel's container conventions. The net architectural position is unchanged by v5.7.0: we would be running two queue subsystems side by side (Illuminate\Queue for Laravel jobs, Symfony Messenger for NATS traffic) with no shared retry, failure, or observability model. That is a deliberate, defensible choice only if NATS JetStream is already the cross-service backbone and PHP is a participant rather than the owner.
Integration feasibility. Technically feasible, moderate-to-high effort. The hard parts are not the transport — DSN parsing (nats-jetstream://, nats-jetstream+tls://), TLS/mTLS, JWT/NKey auth, stream and consumer provisioning, retention and replica settings, batching, DelayStamp scheduling and Nats-Msg-Id deduplication are all implemented and, per the repo, covered by Behat functional scenarios against a real NATS server. The hard parts are the seams:
queue:work equivalent. We must write and supervise an artisan command that owns the Messenger Worker, handles --keepalive, graceful termination, memory limits and restart. Laravel provides none of this for free — no Horizon, no failed_jobs table, no queue:retry. v5.7.0 materially improves this seam: with reconnect: true a worker now polls through a broker outage shorter than one recovery cycle instead of throwing and exiting, and a pull the server ended for its own shutdown or a leadership change returns an empty batch rather than a missing-consumer error. That converts a common class of broker-restart incidents from "supervisor restarts the worker" into "the worker waits." It does not, however, remove the need to write the command.IgbinarySerializer falls back to PhpSerializer, and both wrap a Symfony Envelope. That wire format is PHP-shaped and not consumable by a Go/Node service without replicating the envelope contract. If the reason we want NATS is polyglot messaging, this requires a custom serializer extending AbstractEnveloperSerializer plus a matching contract on every non-PHP consumer.idct/php-nats-jetstream-client is amphp-coroutine based. Running that inside a Laravel worker is one thing; running it under Octane (Swoole/RoadRunner), which already owns an event loop, is a different risk class entirely and should be treated as unsupported until proven. The reconnect mode increases the amount of long-lived loop state and reconnect bookkeeping inside the client, which if anything raises rather than lowers the bar on proving Octane coexistence.psr/log ^3.0 is now a direct requirement. In Symfony the bundle injects the logger service automatically; in Laravel we do this by hand in our service provider — which is straightforward, but means the log channel, format and level are our configuration responsibility, not the package's.Technical risk. Concentrated in maturity and ownership, not in code quality — and the quality signal just strengthened. v5.7.0 reports 669 unit tests, 99.78% statement coverage, and 1008 of 1040 mutants killed (96.9%) with the 32 survivors individually documented, plus reconnect tests that exercise the real client against a scripted connection that can go silent, fail a write, close server-side, hold or refuse dials, and Behat scenarios that restart a real NATS server with docker under a running worker and a running sender. That is unusually rigorous for a package of this size and it is exactly the kind of test surface that makes a fork survivable. Adoption, however, is still the concern — small star count, no known dependents, single primary maintainer. The last release: 2026-10-17 timestamp from the prior assessment remains anomalous relative to a current review and should be re-verified from Packagist. New in this release: a second contributor (Petar Fedorovsky) authored the original reconnect work, and the maintainer redesigned it as a mode across two follow-up PRs — a mild positive on bus factor, not a resolution of it.
Key questions.
TERM with app-side retry limits, or NAK with server-side max_deliver? Who sets and audits max_deliver?reconnect: true with how many max_reconnect_attempts, and does that budget fit inside our supervisor and alerting tolerances? (Client default of 10 attempts ≈ 109 s against a refusing server.)ack_wait — i.e. that an outage can produce a duplicate burst on recovery?failed_jobs, or our existing queue dashboards?Stack fit. Laravel 11/12 and this package share PHP 8.2+, and Laravel already depends on several Symfony components (console, process, http-foundation) — but not symfony/messenger. There is no version conflict, and equally no free integration. Two deltas from v5.7.0 to verify in the spike:
psr/log ^3.0 is now a direct requirement. Confirm the target Laravel version resolves to psr/log 3.x before pinning; a Laravel 10 holdout would take a dependency conflict where previously there was none.logger explicitly and route it to a dedicated Monolog channel (the upstream bundle uses messenger). We should not inherit the default channel — connection lifecycle events are logged at info on every reconnect and errors at error, and both levels need deliberate routing or they will either spam or hide.Recommendation is unchanged: scope it as "NATS integration," not "a composer package we installed."
Migration path. Do not migrate existing Laravel queues. Run this as an additive, event-oriented path: NATS JetStream carries cross-service domain events and integration messages; Illuminate\Queue keeps owning user-facing, latency-sensitive, retry-heavy jobs. This keeps blast radius small, keeps Horizon as the operational source of truth for the things the business already depends on, and lets us evaluate the transport on lower-stakes traffic first.
Compatibility.
auto_setup + reconnect, provisioning is now re-verified after a reconnect, and a send landing on a server without the stream gets one setup() and one republish — this is an improvement, but it also means the app will attempt to mutate infrastructure mid-incident. One more argument for pre-provisioning.igbinary is optional; the PhpSerializer fallback keeps the install simple but hard-codes the PHP-only wire format. Decide this early — it is a one-way door for interop.broadcast, events and notifications do not route through Messenger; any bridging is custom code we own.409 Server Shutdown / 409 Leadership Change on a pull now yields an empty batch, and logger injection occurs when a logger service is present. Cheap upgrade, but our alerting must not treat the new empty-batch case as an anomaly.Sequencing.
symfony/messenger inside a Laravel service provider, register NatsTransportFactory manually with an injected PSR-3 logger, publish and consume one message against a local NATS JetStream container. Gate: works without the Symfony bundle, no container conflicts, no psr/log resolution conflict.AbstractEnveloperSerializer and confirm a non-PHP consumer can read it. Gate: written wire-format contract, or an explicit decision that this is PHP-only.nats:consume with keepalive, memory ceiling, graceful shutdown, structured logging on the dedicated channel, and a health signal. Include reconnect: true with a deliberately chosen max_reconnect_attempts. Gate: survives kill -9 and a docker stop of the broker of at least the length of one recovery cycle, without message loss beyond documented at-least-once semantics, and without the worker exiting.ping_after_idle, connection_timeout, request_timeout tuning); the interaction between max_reconnect_attempts and max_batch_timeout; and a forced reconnect-give-up to observe the discarded-acknowledgement redelivery burst.Exit criteria for abandoning the effort: Octane incompatibility with no workaround, serialization interop proving infeasible within the spike, or maintainer unresponsiveness on a blocking defect.
Maintenance. Plan for ownership, not consumption. Pin the version exactly, vendor the lockfile, mirror the repo internally, and designate a named engineer accountable for upgrades against Symfony 8 and PHP 8.5. Budget modest ongoing effort for tracking upstream releases and one fork-ready contingency. v5.7.0 improves the fork calculus: the codebase is small, the test suite is extensive and mutation-tested with survivors individually justified, and the reconnect work is documented at the granularity of per-operation timeout bounds — all of which shorten the time for a competent engineer to take over. MIT license permits the fork. This remains the main mitigant to the bus-factor risk.
Support. No commercial support. Upstream responsiveness is unproven at this adoption level, though the two-PR redesign with an outside contributor is a better signal than the prior assessment could claim. Internal runbooks must still be written by us: how to drain a consumer, how to replay a subject, how to inspect a stalled stream, how to recover from a wedged amphp loop, and — new — how to interpret and tune the reconnect state machine. Treat "we can debug this ourselves" as a staffing prerequisite, not an aspiration.
Scaling. Horizontal scale is worker-count based, with consumer strategy dictating semantics: shared consumers (batching=1) for competing-consumer throughput, independent consumers where each worker needs the full stream. batching and max_batch_timeout are the throughput/latency dial; stream_replicas provides broker-side HA. Ordering is not guaranteed across multiple consumers on a subjects — any workflow that silently assumes ordered delivery is a latent defect. Stream retention (stream_max_age, stream_max_bytes, stream_max_messages) is a capacity-planning input that must be set deliberately, not left at defaults.
New capacity consideration from v5.7.0: a reconnecting worker is occupied, not free. During recovery it holds its worker slot and its consumer assignment while it waits within its own budget, so a broker outage parks the entire worker pool for the duration of one recovery cycle — roughly 109 s with the client default of 10 attempts against a refusing server, and proportionally longer if we raise max_reconnect_attempts to ride out maintenance windows. That is the right trade for most workloads, but it must be an explicit sizing decision: longer tolerance means fewer workers are doing useful work during an outage, and it means our liveness alerting must distinguish "polling through an outage" from "wedged."
Failure modes to plan for.
retry_handler: nats (NAK) delegates redelivery to the server and depends on max_deliver being set correctly; symfony (TERM) relies on our own retry strategy. Either way, an unset or misconfigured limit yields infinite redelivery on a single bad message. Explicit policy and a monitored counter required.ack_wait. So an outage can be followed by a redelivery wave that is entirely expected behaviour. Handlers must be idempotent, and the runbook must say so before someone diagnoses it as a bug. This reinforces at-least-once, never exactly-once.ack_sync does not ride out an outage. Synchronous acknowledgement that hits the outage still fails after request_timeout regardless of reconnect mode. Any handler using ack_sync needs its own failure path and must not assume the reconnect option covers it.deduplicate option and DeduplicationIdStamp are bounded by the server's duplicate window; retries after the window — including post-outage redelivery — are not deduplicated.ping_after_idle, dial/request timeouts), but a stalled coroutine loop presents as a silent consumer. With reconnect on, the decision logic on a failed PING (recovering / already recovered / open-with-no-reconnect / gave up) is subtle enough that liveness monitoring must key on message age, not process-up or connection status. This is non-negotiable.DelayStamp maps to NATS schedule headers; clock and server-version behaviour should be validated in the pilot.Ramp-up. Moderate. The configuration surface is wide — now slightly wider, with reconnect and max_reconnect_attempts added — and each option has a production consequence: retention, storage, replication, consumers, batching, retry, dedup, TLS, three separate timeouts, and now the reconnect budget, whose correct value is a function of the other timeouts (request_timeout for requests, max_batch_timeout plus one second for pulls) and of our supervisor and alerting tolerances. Budget a week of focused enablement for the two engineers who will own it, and require them to read the configuration builder source and the README's Automatic Reconnect table rather than relying on summaries, since option semantics are the real API here. Do not spread this across a broad team on the assumption it behaves like a Laravel queue driver; it does not, and the failure modes are quieter.
How can I help you explore Laravel packages today?