Reference documentation for the native extension: the reliability contract, troubleshooting, and installation/distribution.
Contents
- Reliability — the at-least-once contract, safety modes, recovery, duplicates, metrics
- Troubleshooting — common errors and diagnosis (extension not loaded, connection failures, topology and permission errors, recovery diagnostics, backpressure, delayed messages)
- Installation — prerequisites, PIE, Homebrew, Docker, multi-PHP, upgrades and rollback, distribution model
Rabbit RS provides at-least-once delivery. Once a message is accepted into the confirmed delivery path, silent loss is unacceptable; duplicates are permitted and must remain identifiable and measurable. The guarantee is scoped to the live process: the publisher replay buffer is process memory, and a PHP crash can drop publications the broker never received — see What the replay buffer is not.
The documented exception is safety = blind: an explicit fire-and-forget mode (silent loss possible), set per connection (safety key) or package-wide (env RABBIT_RS_SAFETY) as well as in the raw native extension configuration. Publisher confirms and mandatory routing are derived from safety by the ConnectionCompiler — there are no separate confirms/mandatory config keys; see Configuration — Safety modes.
The delivery contract is:
- No silent loss — every publish on the confirmed delivery path (
safemode) is either confirmed or the caller is notified of failure - Duplicates permitted — in failure windows, the same message may be delivered more than once
- Duplicates identifiable — each message carries a stable
message_id(UUID from Laravel payload) - Duplicates measurable — metrics track redeliveries and duplicate counts
This means your jobs must be idempotent. Use the message_id to detect and handle duplicates at the application level.
Publisher confirms are enabled by default (safety = "safe", the default, derives confirms = true). When enabled:
- The publisher calls
confirm.selecton the channel - Each published message is assigned a sequence number
- The broker sends
basic.ack(confirmed) orbasic.nack(rejected) with the sequence number - The publish call resolves once its confirm is received. Batched publishes are pipelined: the call returns before confirmations resolve, and unconfirmed outcomes surface at the next operation (the next publish flush,
drainSettlementErrors(), orstats())
A confirm timeout (connection key confirm_timeout, default 30000 ms) ensures the call does not hang indefinitely. During a recovery, a publish parked in replay is retried once with a fresh deadline; a confirm timeout on a live connection stays terminal (unknown outcome → no automatic resend).
The publish buffer's age-flush timer (publisher.flush_interval, default 1 ms) arms on the first buffered publication, so a lone publish is flushed to the broker even if the process never publishes, pops, or flushes again. Measured on the reference lab (RabbitMQ 4.2.9, 3-node cluster, release extension): publish-to-delivery p50 ≈ 1.9 ms, p99 ≤ 6.4 ms, max 7.3 ms across 630 lone publishes — cold, warm, and under concurrent integration-suite load, in safe and blind modes (benchmarks/results/flush-timer-latency/).
Measuring this yourself? Do not observe with the management API's sampled per-queue depth: on RabbitMQ 4.2.9 the messages_ready sample does not surface promptly (often never appears for a fresh queue, on classic and quorum queues alike). Observe the landing with a consumer arrival timestamp or the management API's live POST /api/queues/{vhost}/{queue}/get endpoint — sampled depth produced the phantom multi-second tails reported against v0.2.2 (issue #255).
Mandatory routing is always on in safe mode (safety = "safe", which derives mandatory = true). When enabled:
- The broker returns unroutable messages via
basic.returninstead of silently dropping them basic.returnis processed before the correspondingbasic.ack— a return takes precedence over a following ACK- The publish call resolves with a
Returnedoutcome, and the Laravel queue driver throws aQueueException
There is no separate mandatory config key: it is derived from safety (safe → confirms + mandatory, unsafe → neither — a synchronous socket write without outcome tracking, blind → neither — fire-and-forget through a bounded pump) — a connection key named mandatory hits the unknown-key rejection with an actionable error. The only mode with mandatory routing is safe.
Publications carrying the x-delay header (delay plugin mode) are never mandatory on the wire, even in safe mode: the rabbitmq_delayed_message_exchange plugin returns every mandatory delayed publish as unroutable (basic.return), so mandatory routing is suppressed for plugin-routed delayed messages. Publisher confirms remain enabled — the publish still resolves only after the broker confirm, and the no-silent-loss contract is preserved by the confirmed rabbit-rs.delayed binding (see Topology — Delay routing). Delayed messages published through TTL bucket queues carry no x-delay header on the wire and remain mandatory.
Rabbit RS handles connection loss automatically. The connection states are Disconnected, Connecting, Ready, Recovering, FailedPermanent, and Closed:
Disconnected → Connecting → Ready → Recovering → Ready
| |
+→ FailedPermanent ←----+
(permanent errors: authentication failure,
incompatible topology)
close() → Closed (from any state)
Recovery follows a deterministic order:
- Connection — re-establish the TCP connection and AMQP negotiation (the generation increments)
- Publisher channel — open a fresh publisher channel
- Topology — declare or verify exchanges, then queues, then bindings
- Publisher replay — replay unconfirmed publications from the bounded buffer
- Consumers — per subscription: open the consumer channel, re-apply QoS, re-register
basic.consume
This order ensures that consumers are only re-registered after their queues and bindings exist, and that publishers resume — replay included — after the topology is restored, before consumers re-register.
With multiple brokers, each broker recovers independently through its own coordinator: one broker recovering never blocks consumption from the others. When a broker's consumer set is replaced after recovery, the composed multi-broker consumer surfaces a one-shot ConnectionException ("broker source replaced by recovery; re-fetch consumer") — re-fetch the consumer to resume deliveries from that broker (see Multiple brokers and vhosts).
Retries use exponential backoff with jitter:
- Initial backoff: 100 ms
- Multiplier: 2x
- Maximum: 30 seconds
- Jitter: 20%
Permanent errors (authentication failures, incompatible topology) are not retried in publish contexts. Consumer workers may continue retrying according to their own policy.
Each delivery carries an opaque token containing:
- Connection identity
- Channel ID
- Consumer tag
- Delivery tag
- Connection generation
After a connection recovery, the generation increments. If the PHP code attempts to ACK a delivery from an old generation, the extension rejects the stale ACK. RabbitMQ redelivers the message.
This handles the race condition where:
- A job is delivered to PHP
- The job completes, but the ACK hasn't reached the broker
- The connection drops
- The connection recovers (new generation)
- PHP attempts to ACK the old delivery
- The extension rejects the stale ACK
- RabbitMQ redelivers the message
The job may be executed twice. This is expected and why jobs must be idempotent.
Closing a consumer (or its pool) flushes pending and queued acknowledgements to the broker within a bounded 500 ms budget before the channels close; settlements still unacknowledged after the budget are abandoned to redelivery, preserving at-least-once. A closed consumer set never serves deliveries again: every entry point (next(), tryNext(), nextBatch()) refuses a closed set with the recoverable pop-time closed error instead of handing out buffered deliveries whose settlements would route to a dead actor — unserviced orphans are left to the broker's redelivery on the next generation.
Deliveries whose size alone exceeds max_buffered_bytes are settled terminally using the poison policy: basic.reject(requeue=false) toward the dead-letter exchange when one is configured, otherwise an explicit acknowledge with a typed settlement error. In no_ack mode the broker has already auto-acked the delivery at hand-off, so no wire settlement is issued for it — a wire ack or reject would hit an unknown delivery tag (PRECONDITION_FAILED 406), close the channel, and redeliver the same message in a churn loop; the terminal outcome is recorded as the typed settlement error only. They are never requeued and never parked indefinitely. Operators should align the broker's max_message_size with consumer max_buffered_bytes so oversized payloads are rejected at publish time; the consumer-side terminal path is the last line of defense.
When a connection drops before a publish is confirmed, the state is ambiguous — the broker may or may not have received the message. Rabbit RS handles this by:
- Classifying unconfirmed publications as ambiguous — the publish call does not resolve immediately
- Placing them in a bounded in-memory replay buffer — with the same
message_id, payload, destination, and original deadline - Replaying them after recovery — once the topology is restored and a new confirm-enabled channel is open
- Reusing the original deadline — the deadline is never reset by a reconnection
The replay buffer is bounded by the publisher's global buffer capacity — a shared budget for in-flight confirms and replayed publications (1024 publications and 64 MiB of buffered payload bytes by default). When the budget is exhausted, new publications receive Backpressure instead of being accepted.
The replay buffer is in-memory only. It survives connection drops but not a PHP process crash. If the PHP process crashes, all unconfirmed publications in the buffer are lost.
For durability beyond a process crash, use an external outbox pattern:
- Write the job to a persistent store (database) within the same transaction as your business operation
- A separate process reads from the outbox and publishes to RabbitMQ
- Delete the outbox entry after a publisher confirm
Rabbit RS does not include an outbox in V1. The in-memory replay buffer covers the common case of transient network failures.
Duplicates are expected and normal. They occur in these scenarios:
| Scenario | Cause |
|---|---|
| Connection drop after delivery, before ACK | Stale ACK rejected, RabbitMQ redelivers |
| Connection drop after publish, before confirm | Replay buffer republicates; broker may have received the original |
| Worker crash with in-flight jobs | RabbitMQ redelivers unacked messages |
- Make jobs idempotent — use
message_idor business keys to detect duplicate work - Use
attempts()— theRabbitMqJob::attempts()method returns the delivery count fromx-acquired-countorx-delivery-countheaders - Set
delivery_limit— quorum queues dead-letter messages that exceed the limit;dead_lettermust be configured whendelivery_limitis set - Monitor duplicates — see Measuring duplicates below; cross-process
messages_redeliveredis the observable duplicate signal
Native pool metrics — including duplicates_total — are per-process by design: they count deliveries that the broker flagged as redeliveries and that that process settled. Every worker process has its own counters, and rabbit-rs:status creates a brand-new pool inside the artisan CLI process, so the native metrics it prints are same-process only and read zero in a fresh CLI process.
php artisan rabbit-rs:status therefore observes two distinct things:
Native pool metrics (same-process only). Useful from inside a long-lived process; always zeros in a fresh CLI process:
reconnects_total— number of connection recoveries (each can cause duplicates)deliveries_total— total deliveries receivedacks_total/rejects_total— settlement countsduplicates_total— deliveries the broker flagged as redeliveries, settled by this process
Queue counters (cross-process). Set the optional per-connection key queue.connections.<name>.management_url (e.g. http://broker-host:15672) and the status command fetches per-queue counters from the RabbitMQ management API, across every process touching the queue:
messages_delivered/messages_acked— broker-side delivery and settlement totalsmessages_redelivered— an approximate duplicate signal: at-least-once also redelivers after a consumer crash, so a redelivery is not necessarily a duplicate. Watch for sustained growth correlated with reconnects.
An in-process Prometheus exporter is the planned evolution for per-process counters; it is deliberately not provided today.
For operating on these signals — incident playbooks, example alert rules, and a dashboard definition — see docs/operations/ (runbook.md, alerts.md, dashboard.json).
Use an external outbox when:
- You need durability across PHP process crashes (not just connection drops)
- You publish within a database transaction and need the publish to be transactional with the database write
- You cannot tolerate any message loss, even in the ambiguous window
Without an outbox, the in-memory replay buffer covers transient network failures but not process crashes. For most Laravel applications, the default behavior is sufficient — PHP workers are typically supervised by Supervisor or Kubernetes and restart automatically.
Rabbit RS runs as a native PHP extension: an uncaught Rust unwind crossing the FFI boundary aborts the whole PHP process. The core therefore keeps panics out of every code path reachable from a PHP operation, and routes diagnostics through the log facade (rabbit_rs_core::log) instead of stderr.
- Production code must not call
unwrap(),expect(), or thepanic!family on paths reachable from a PHP operation. Prefer typed errors with actionable context. - A remaining
expect/unwrapis accepted only as a documented, proven invariant — one that cannot fire without a prior logic bug in the same synchronous block (see the audit below). - Panics in
#[cfg(test)]code are out of scope; tests may panic freely. - Background Tokio tasks must terminate cleanly on failure (log through the facade, then return) instead of panicking inside a spawned task.
- The core depends on no logging framework and never writes to stderr.
- Embedders install one process-wide sink (
rabbit_rs_core::log::install); the first installation wins and later calls are rejected, which keeps forks and repeated initializations deterministic. - Without an installed sink the core is silent; records emitted before the first install are dropped, so install at startup before spawning pools.
- Redaction contract: call sites only log broker names, connection generations, and transport error messages — never credentials, complete broker URIs, or private certificate material. Sinks must preserve this when forwarding.
rg -n 'unwrap\(\)|expect\(|panic!|unreachable!|todo!|unimplemented!' over crates/rabbit-rs-core/src and crates/rabbit-rs-php/src, restricted to production code (#[cfg(test)] modules excluded).
| Site | Problem | Resolution |
|---|---|---|
pool/recovery_coordinator.rs wait_for_state |
expect on the state watch when the coordinator task had stopped: panic reachable from PHP-facing waits |
Returns ConnectionState::Closed when the watch dies; state() also reports Closed for a dead watch so pool loops observe a terminal state |
pool/recovery_coordinator.rs run_coordinator |
expect("connection actor started") inside a spawned task |
Logs through the facade (Level::Error) and terminates the task cleanly |
pool/recovery_coordinator.rs recovery failure |
eprintln! leaked diagnostics to stderr |
Routed through the log facade (Level::Warn), carrying the typed CoordinatorError |
| Site | Invariant |
|---|---|
client.rs topology_plan() fallback .expect("external mode always compiles") |
TopologyPlan::compile on an empty External-mode plan validates nothing and cannot fail; the expect guards a compile-time-true property |
consumer/actor.rs drain_pending .expect("front checked above") |
pop_front runs only after front() returned Some in the same synchronous block with no mutation in between |
consumer/attempts.rs DEFAULT_MAX_ATTEMPTS_NON_ZERO |
Const-evaluated match; the panic! fires at compile time if the constant is ever zero, never at runtime |
topology/delay.rs write!(...).expect("writing to String is infallible") |
fmt::Write for String cannot fail |
callbacks.rs (callback registry), classes/bridge.rs, and classes/publish_buffer.rs call .expect("... mutex poisoned") on std::sync::Mutex locks. A poisoned mutex requires a prior panic while the lock was held; the critical sections in these types perform no panicking operations, so the poison state is unreachable in practice. These sites stay documented rather than converted: converting them would trade a proven invariant for error propagation through FFI paths that have no meaningful recovery.
CoordinatorError is a typed enum (Topology/Transport/Publisher/Consumer/Internal) whose variants carry the typed source error; Display messages keep the previously surfaced context. Callers must classify through variants, never through string matching.
Permanent failures surface their reason. A pool whose connection has failed permanently fails admin operations and consumer acquisition immediately with its published permanent-failure reason (instead of waiting out a timeout and surfacing raw transport state text such as invalid connection state: Closed), and a publish that fails on the publisher actor's permanent-failure path carries the same reason (broker connection failed permanently: {reason}) instead of a generic failure. The reason is what the typed error already carried internally — an authentication rejection, an incompatible topology, and so on — now visible at every entry point that reports the failure.
The AMQP transport always verifies the broker certificate (rustls, verify: peer — the only accepted value, and the default). A custom ca_cert chain extends the platform trust store, and a client identity (client_cert + client_key) enables mTLS: it is supported and certified by the lab test suite, which proves a connection with a client certificate succeeds against a broker listener that rejects anonymous clients (fail_if_no_peer_cert = true), and that the same listener rejects connections without one.
TLS server name indication (SNI) and certificate hostname verification always use the AMQP connection host. A server_name override is not possible with the underlying AMQP transport (lapin 4.11) and is rejected at validation when it differs from the first configured host — a documented gap tracked for post-1.0 (#164, #166).
Error:
The Rabbit RS Laravel driver requires ext-rabbit_rs ^0.3.10 to be loaded.
Solution:
Install the native extension:
pie install goopil/rabbit-rs-nativeVerify it is loaded:
php -m | grep rabbit_rs
php --ri rabbit_rsIf the extension is installed but not loaded, check your PHP configuration:
# Find the PHP config directory
php --ini
# Check if the extension is enabled
grep rabbit_rs /path/to/php.ini
# Should show: extension=rabbit_rsError:
ConnectionException: Failed to connect to broker
Diagnosis:
- Verify RabbitMQ is reachable:
# Check TCP connectivity
nc -zv rabbit-host 5672
# Check AMQP handshake (if rabbitmqadmin is installed)
rabbitmqadmin --host=rabbit-host --port=5672 list vhosts- Check credentials and vhost:
# Verify vhost exists
rabbitmqctl list_vhosts | grep '/your-vhost'
# Verify user permissions
rabbitmqctl list_permissions -p /your-vhost- Check TLS configuration:
# Test TLS connection
openssl s_client -connect rabbit-host:5671 -CAfile /path/to/ca.pem- Check the status command:
php artisan rabbit-rs:statusCommon causes:
| Cause | Solution |
|---|---|
| Wrong host/port | Check RABBIT_RS_HOSTS env var |
| Wrong vhost | Check RABBIT_RS_VHOST — vhosts are case-sensitive |
| Wrong credentials | Check RABBIT_RS_USERNAME and RABBIT_RS_PASSWORD |
| TLS mismatch | Ensure RABBIT_RS_TLS=true when broker requires TLS |
| Firewall | Ensure port 5672 (or 5671 for TLS) is open |
| RabbitMQ not running | Start the RabbitMQ service |
Error:
PRECONDITION_FAILED - inequivalent arg 'x-queue-type' for queue 'orders'
Cause: The queue exists with different arguments than what Rabbit RS is trying to declare.
Solutions:
- Use
verifymode to check without modifying:
RABBIT_RS_TOPOLOGY_MODE=verify- Use
externalmode if an external system manages topology:
RABBIT_RS_TOPOLOGY_MODE=external- Delete and recreate the queue (data loss — use with caution):
rabbitmqctl delete_queue orders- Align your config with the existing queue arguments (type, durability, delivery_limit).
Error:
NOT_FOUND - no exchange 'laravel.jobs'
Cause: In external or verify mode, the exchange does not exist.
Solution: Switch to declare mode, or create the exchange manually:
rabbitmqadmin declare exchange name=laravel.jobs type=direct durable=trueError:
ACCESS_REFUSED - access to queue 'orders' refused
Cause: The configured user lacks permissions on the vhost or queue.
Solution:
# Grant permissions
rabbitmqctl set_permissions -p /your-vhost username ".*" ".*" ".*"
# For read-only (verify mode)
rabbitmqctl set_permissions -p /your-vhost username "^amq\.|^laravel\." "^amq\.|^laravel\." ".*"Error:
ACCESS_REFUSED - access to vhost '/production' refused
Cause: The user does not have access to the vhost.
Solution:
# Grant vhost access
rabbitmqctl set_vhost_permissions -p /production username ".*" ".*" ".*"Symptom: The worker reconnects frequently (high reconnects_total).
Diagnosis:
php artisan rabbit-rs:statusCheck:
reconnects_total— if increasing, the connection is unstablebackpressure_total— if high, the broker may be overloadedconfirmation_latency_p99— if high, the broker is slow to confirm
Common causes:
| Cause | Diagnostic | Solution |
|---|---|---|
| Heartbeat timeout | Check RABBIT_RS_HEARTBEAT (default 30s) |
Increase heartbeat or check network |
| Network instability | Check for packet loss, DNS issues | Fix network or use direct IP |
| Broker overload | Check RabbitMQ management UI | Scale RabbitMQ or reduce publish rate |
| Firewall idle timeout | Connection drops after N seconds of idle | Reduce heartbeat below the idle timeout |
Symptom: Messages are redelivered after recovery.
This is expected behavior. When a connection drops, RabbitMQ redelivers unacked messages. The worker may process the same message twice. Ensure your jobs are idempotent. See Reliability — Duplicates.
Enable debug logging by listening to events:
// In a service provider or EventServiceProvider
use Goopil\RabbitRs\Laravel\Events\ConnectionStateChanged;
use Goopil\RabbitRs\Laravel\Events\BackpressureDetected;
use Illuminate\Support\Facades\Log;
Event::listen(ConnectionStateChanged::class, function (ConnectionStateChanged $event) {
Log::debug("Rabbit RS: broker {$event->broker} → {$event->state} (gen {$event->generation})");
});
Event::listen(BackpressureDetected::class, function (BackpressureDetected $event) {
Log::warning("Rabbit RS: backpressure on {$event->broker}: {$event->inFlight}/{$event->capacity}");
});Check queue depth:
# Via Rabbit RS
php artisan tinker
>>> Queue::connection('rabbit-rs')->size('orders.high')
# Via rabbitmqctl
rabbitmqctl list_queues -p /your-vhost name messagesSymptom: Log shows stale generation warnings during recovery.
This is expected behavior. After a connection recovery, ACKs from the old generation are rejected to prevent double-settlement. RabbitMQ redelivers the message. The job may execute twice — ensure idempotency. See Reliability — Stale ACK rejection.
Symptom: BackpressureException thrown during publish.
Cause: The publisher's bounded capacity is full (in-flight confirms + replay buffer).
Solutions:
- Reduce publish rate — batch jobs, add delays
- Scale consumers — more workers drain queues faster
- Check broker health — high confirmation latency indicates broker saturation
- Scale the publisher buffer — the publisher's bounded capacity (1024 publications by default) is currently not configurable: it is not exposed through the Laravel package config (
config/rabbit-rs.php), and the raw native extension configuration does not accept abuffer_capacitykey either. Until it is plumbed through, the mitigations above are the only levers.
See Operations — Backpressure.
Symptom: later() jobs are not delivered after the delay.
Diagnosis:
- Check the delay mode:
RABBIT_RS_DELAY_MODE=auto- If using
pluginmode, verify the plugin is installed:
rabbitmq-plugins list | grep delay
# Should show: rabbitmq_delayed_message_exchangeThe driver guards this for you. With a management_url configured, the
guard probes the management API; without one it probes the broker natively
through the rabbit_rs extension (a throwaway delayed-exchange declare that
only succeeds with the plugin installed). Either way, plugin
mode throws DelayPluginMissingException on the first delayed publish when
the probe proves the plugin absent, and auto mode degrades to the
ttl bucket queues at connection compile time — deferred jobs are never
published into the main queue or silently lost. See the Laravel package's
Management API usage for
the per-feature path matrix.
- If using
ttlmode, check the TTL queues exist:
rabbitmqctl list_queues -p /your-vhost name messages | grep delay- Check the delay buckets — delays are rounded up to the nearest bucket:
# With buckets [1, 5, 30, 120]
# A 3-second delay → bucket 5 (delivered after ~5 seconds)- Check the publisher safety mode — in
blindmode, delayed jobs are published immediately: the pump bypasses delay routing, sodelay_ms > 0is not honored. Usesafeorunsafefor delay routing:
RABBIT_RS_SAFETY=safeIf you cannot resolve an issue:
- Run
php artisan rabbit-rs:status --format=jsonand save the output - Run
php --ri rabbit_rsand save the output - Check Reliability for delivery semantics
- Check the troubleshooting checklist for known issues
- Open an issue on GitHub with the diagnostic output
Installing the Rabbit RS native extension and the Laravel queue driver.
- PHP 8.2, 8.3, 8.4, or 8.5 (NTS and ZTS on Linux; NTS only on macOS — ZTS deferred, see Thread safety)
- Linux x86_64 or ARM64 (glibc or musl), or macOS Apple Silicon
- RabbitMQ 4.2.9 or newer (reachable from your PHP process — the CI lab runs 4.2.9)
- PIE 1.4.10+ for extension installation (the version the release pipeline validates against)
- Composer for the Laravel queue driver
macOS (Apple Silicon) installs through PIE like Linux —
composer.jsondeclaresos-families: ["linux", "darwin"]— and the release pipeline validates the macOSpie installpath on an Apple Silicon runner. Homebrew remains an alternative channel (see How pre-packaged binaries work). Windows is not supported in V1.
ZTS binaries ship from 0.3.14 across the full Linux grid (glibc and musl, x86_64 and arm64 — the same matrix grpc-php-rs publishes); macOS ZTS is deliberately deferred — ZTS demand is Linux/Docker (the official FrankenPHP images), revisit on demand. PIE selects the matching binary for a ZTS PHP installation (composer.json declares "support-zts": true).
The extension keeps a process-global runtime and connection registry whose shared state is mutex/atomic-protected — the Rust core is memory-safe under concurrent PHP threads, and Zend values never cross threads. The ZTS-specific lifecycle hazard is fixed: the module's MSHUTDOWN no longer closes the process-global registry on ZTS builds (the close is gated on non-ZTS), so a worker-thread teardown — a FrankenPHP worker recycle — never kills the shared runtime under surviving threads (thread-safety audit). Shared pools give competing-consumer semantics: deliveries are split between PHP threads, a one-shot re-fetch signal can be consumed by whichever thread calls next() next, and one request closing a Consumer closes the shared composite for every request on that fingerprint.
Certification: the nightly FrankenPHP harness runs three workers recycling under sustained publish load (scripts/test-octane-runtime.sh) with an in-image ZTS build, green both before and after the MSHUTDOWN fix (runs 40231010571 and 40231733197). The fix is kept as static-analysis hardening — the teardown panic it guards against was not reproducible through the graceful recycle path.
pie install goopil/rabbit-rs-nativePIE selects the correct pre-compiled binary for your environment:
- PHP version (8.2, 8.3, 8.4, or 8.5)
- Architecture (x86_64 or arm64)
- Operating system (linux or darwin)
- libc (glibc or musl on Linux, bsdlibc on macOS)
- Thread safety (NTS and ZTS on Linux; NTS only on macOS)
It copies the shared object (rabbit_rs.so) to your PHP extension directory and enables it in the active PHP configuration.
php --ri rabbit_rsExpected output:
rabbit_rs
Rabbit RS - High-performance RabbitMQ transport for PHP and Laravel, powered by Rust
Version => 0.3.6
...
Use PIE in a multi-stage Dockerfile. No dedicated Rabbit RS image is needed:
FROM php:8.4-cli AS base
# Install PIE
RUN curl -L https://github.com/php/pie/releases/latest/download/pie.phar -o /usr/local/bin/pie \
&& chmod +x /usr/local/bin/pie
# Install the extension
RUN pie install goopil/rabbit-rs-native
# Verify
RUN php --ri rabbit_rs
# Install Composer and the Laravel queue driver
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
RUN composer require goopil/rabbit-rs-laravel
# ... your applicationFor a complete Dockerfile example, see examples/laravel/Dockerfile.
Apple Silicon (ARM64) users have two alternatives to PIE:
Homebrew:
brew tap goopil/rabbit-rs
brew install rabbit-rsRequires PHP 8.2, 8.3, 8.4, or 8.5 (Homebrew PHP for the tap).
Manual binary install — download the pre-compiled binary from GitHub Releases and load it by hand:
# Download the matching asset for your PHP version
unzip php_rabbit_rs-*_php8.4-arm64-darwin-bsdlibc-nts.zip
cp rabbit_rs.so $(php-config --extension-dir)/rabbit_rs.so
echo "extension=rabbit_rs" > $(php-config --ini-dir)/ext-rabbit_rs.ini
php -m | grep rabbit_rsIntel Macs (x86_64) are not distributed as pre-compiled binaries — build from source instead (see Local compilation with Cargo).
composer require goopil/rabbit-rs-laravelComposer installs the PHP package. It does not install or modify system PHP binaries — that is PIE's job — and it does not verify the extension either: ext-rabbit_rs is a Composer suggestion (^0.3.10), so composer install succeeds without it, and a connection resolved without the extension (or with a version outside the constraint) fails at connection resolution with a typed error naming the install command (see Why Composer doesn't modify system PHP).
The package auto-discovers the service provider in Laravel 12 and 13. If you disabled auto-discovery, register it manually:
// config/app.php
'providers' => [
// ...
Goopil\RabbitRs\Laravel\RabbitMqServiceProvider::class,
],php artisan vendor:publish --tag="rabbit-rs-config"This creates config/rabbit-rs.php with sensible defaults. See Configuration for the full reference.
php artisan rabbit-rs:statusThis displays connection state, pool metrics, and consumer stats. For machine-readable output:
php artisan rabbit-rs:status --format=jsonFor contributors or environments without PIE:
# Clone the repository
git clone https://github.com/Goopil/php-rabbit-rs.git
cd php-rabbit-rs
# Build the extension in release mode
cargo build --release -p rabbit-rs-php
# Install into the current PHP
./scripts/install.sh --releaseThe install.sh script wraps cargo php install with the correct manifest path (the workspace root Cargo.toml is workspace-only, so cargo-php needs the package manifest at crates/rabbit-rs-php/Cargo.toml).
- Rust 1.98.1 (pinned in
rust-toolchain.toml) cargo-php(install withcargo install cargo-php)- PHP 8.2, 8.3, 8.4, or 8.5 with development headers
libssl-dev(oropenssl-devel/openssl-devdepending on your distro)
The native extension is a binary shared object (rabbit_rs.so) that must be compiled for your specific PHP version, architecture, libc, and thread-safety mode. Composer is a PHP dependency manager — it handles PHP source packages, not system binaries.
The separation is:
| Tool | Responsibility |
|---|---|
| PIE | Downloads and installs the correct pre-compiled .so binary |
| Composer | Installs the Laravel queue driver (PHP source); ext-rabbit_rs stays a suggestion — connections fail at connection resolution until the extension is loaded |
The Laravel driver's composer.json declares ext-rabbit_rs as a suggestion (^0.3.10), not a requirement: composer install succeeds without the extension. The constraint is enforced at connection resolution — the driver fails with a typed error when the extension is missing or its version falls outside the constraint. Composer never installs the binary — that is PIE's role.
If you have multiple PHP installations, PIE and cargo-php target the PHP found in your PATH. To target a specific PHP, run them with that PHP's interpreter and ensure its php-config/phpize come first in the PATH:
# With PIE (uses the php-config/phpize in PATH)
/path/to/php/bin/php /usr/local/bin/pie install goopil/rabbit-rs-native
# With cargo-php (php-config of the target PHP first in PATH)
PATH="/path/to/php/bin:$PATH" ./scripts/install.sh --releasePIE installs are versioned replacements: installing a different version swaps the rabbit_rs.so binary and updates the active PHP configuration in place. Upgrades and rollbacks use the same pie install command with an explicit release tag.
Upgrade (or reinstall) an exact version:
pie install goopil/rabbit-rs-native:v0.3.5Rollback = install the previous tag:
pie install goopil/rabbit-rs-native:v0.3.4Check which version is active before and after:
php --ri rabbit_rsKeep the Laravel queue driver in sync: goopil/rabbit-rs-laravel tracks a specific ext-rabbit_rs constraint (^0.3.10). When moving across a version boundary — in either direction — upgrade or roll back the extension and the driver together. Composer cannot check loaded extensions (the constraint lives in suggest), so the driver enforces the constraint itself with a typed error at connection resolution: a half-upgraded system (new driver with old extension, or the reverse) fails loudly on the first connection instead of going unnoticed.
Every release exercises these paths in CI before it is finalized: the release pipeline installs the previous published release, upgrades it to the new release, and rolls back again (see End-to-end PIE validation).
Rabbit RS distributes two packages in synchronized releases:
goopil/rabbit-rs-native— the native PHP extension, installed via PIEgoopil/rabbit-rs-laravel— the Laravel queue driver, installed via Composer
Both packages share the same version number: a release 1.2.0 produces goopil/rabbit-rs-native 1.2.0 and goopil/rabbit-rs-laravel 1.2.0. The Laravel package suggests ext-rabbit_rs ^0.3.10 — the constraint tracks the extension version until 1.0 and is enforced by a typed error at connection resolution (see Why Composer doesn't modify system PHP).
The CI produces 36 pre-compiled release artifacts per release — 32 Linux cells (PHP 8.2, 8.3, 8.4, and 8.5 × x86_64/arm64 × glibc/musl × NTS/ZTS — the same Linux grid grpc-php-rs ships) plus 4 macOS arm64 cells (8.2–8.5, NTS) — covering every supported combination. macOS ZTS is deliberately deferred (see Thread safety).
The authoritative matrix is the build matrix in .github/workflows/release.yml. release/pie-matrix.json predates the widened matrix (PHP 8.2/8.3 and ZTS) and still lists the V1 NTS-only entries; it and scripts/validate-distribution.sh are pending the matching update.
Every cell above is compiled, load-smoked, checksum-verified, and attested by the release pipeline — but "loads" is not "works against a broker". Real-broker coverage (publish + confirms, consume + ack, one Toxiproxy outage/recovery scenario via scripts/amqp-smoke.sh, issue #227) is narrower and proven per tier:
| PHP | Architecture | libc | Functional proof |
|---|---|---|---|
| 8.4 | x86_64 | glibc | CI integration job (full Rust + Laravel integration suites) and the release PIE install smoke |
| 8.4 | x86_64 | musl | Nightly functional matrix docker cell and the release PIE install smoke |
| 8.4 | arm64 | glibc | Nightly functional matrix docker cell and the release PIE install smoke |
| 8.4 | arm64 | musl | Nightly functional matrix docker cell and the release PIE install smoke |
| 8.5 | x86_64 | glibc | Nightly functional matrix (full integration suites + AMQP smoke) and the release PIE install smoke |
| 8.5 | x86_64 | musl | Build-only (shares the musl runtime path proven by the 8.4 musl smoke) |
| 8.5 | arm64 | glibc | Build-only (shares the glibc runtime path proven by the 8.5 glibc x86_64 integration) |
| 8.5 | arm64 | musl | Build-only (shares the musl runtime path proven by the 8.4 musl smoke) |
| 8.4 | arm64 | darwin | Nightly functional matrix macos-arm64 cell (dispatch/manual only): load + publish/confirm + consume/ack against a local Homebrew rabbitmq — no recovery scenario (no Toxiproxy on macOS runners) |
| 8.5 | arm64 | darwin | Build-only (shares the 8.4 macOS ARM64 functional cell) |
"Build-only" means the release pipeline compiles the artifact for that cell and proves it loads with the target PHP, but no AMQP traffic is exercised there; the runtime path it shares with a functional sibling is the only broker-level evidence. The functional bar itself is deliberately small — extension load, 5 messages published and confirmed, consumed and acked with the queue drained, and one outage/recovery scenario (publish 2 into a disabled Toxiproxy proxy, require both to buffer and confirm after the network heals) — so a release asset that cannot deliver against a real broker fails somewhere in CI, not in production.
The ZTS Linux cells follow the same bar: every ZTS cell is load-smoked against its pinned php:N-zts image at build time with an explicit thread-safety assertion, and the glibc x86_64 ZTS path (PHP 8.4 and 8.5) additionally runs the release PIE install + AMQP smoke; the remaining ZTS cells share those runtime paths.
Each artifact is a ZIP archive containing a single rabbit_rs.so compiled for the exact combination of PHP version, architecture, operating system, libc, and thread-safety mode. The naming convention follows PIE's expected format, which includes the v prefix from the git tag:
php_rabbit_rs-v{version}_php{php}-{arch}-{os}-{libc}-{ts}.zip
For example:
php_rabbit_rs-v1.2.0_php8.5-x86_64-linux-glibc-nts.zip
php_rabbit_rs-v1.2.0_php8.4-x86_64-linux-glibc-zts.zip
php_rabbit_rs-v1.2.0_php8.5-arm64-darwin-bsdlibc-nts.zip
Every artifact carries an explicit thread-safety suffix (-nts or -zts). PIE (1.4.10+, the version the release pipeline validates) resolves NTS assets matched either with or without the -nts suffix (and requires -zts for ZTS builds); the explicit suffix is the repository convention so that asset names are unambiguous and self-describing. On macOS, PIE derives the bsdlibc libc token from its LibcFlavour detection (otool, shipped with Xcode Command Line Tools, resolves to bsdlibc). The authoritative matrix lives in .github/workflows/release.yml (-${{ matrix.ts }} appended to every asset name) via the .github/actions/package-release-asset composite action; release/pie-matrix.json and scripts/validate-distribution.sh predate the widened matrix (PHP 8.2/8.3, ZTS) and are pending the matching update.
macOS artifacts (arm64-darwin-bsdlibc-nts) are part of the PIE matrix — composer.json declares os-families: ["linux", "darwin"]; macOS ZTS is deliberately deferred (see Thread safety). The release pipeline compiles and smoke-loads both macOS builds, and the Homebrew formula (alternative install channel) consumes the same assets, so there is a single name per binary; the formula is audited and install-tested on a macOS ARM64 runner by homebrew-formula-test.yml (formula-affecting pull requests and manual dispatch). The release pipeline itself does not reinstall through Homebrew on macOS — if that install path regresses, the formula test workflow fails on its next run rather than blocking the release.
Once the release is published, the release pipeline blocks on two verification stages against the published release:
verify-pie-installruns a realpie installon the install paths: Linux glibc x86_64 (PHP 8.2, 8.3, 8.4, and 8.5) on native runners, Linux glibc arm64, musl x86_64, and musl arm64 (PHP 8.4) plus the ZTS glibc x86_64 path (PHP 8.4 and 8.5) inside the same digest-pinned, multi-arch PHP images the build job uses, and macOS arm64 (PHP 8.4) on an Apple Silicon runner. Each job asserts thatrabbit_rsloads and thatphpversion('rabbit_rs')equals the release version.verify-pie-upgrade-rollbackinstalls the previous published release, upgrades it to the new release, then rolls back to the previous one, asserting the installed extension version at each step. A missing previous release fails the job loudly instead of silently skipping the gate.
The Homebrew formula update and the Laravel package split run only after both stages succeed, so a release that PIE cannot install, upgrade, or roll back never reaches those channels.
Each release archive is accompanied by:
- A SHA-256 checksum file (
.sha256) - A CycloneDX SBOM in JSON format (
.sbom.json), generated from therabbit-rs-phpcrate viacargo-cyclonedx0.5.9 - A GitHub build provenance attestation (SLSA v1), signed with Sigstore using the workflow's OIDC identity
- A GitHub SBOM attestation binding the SBOM to the ZIP artifact
Attestations are stored in the GitHub attestations API and verified with:
gh attestation verify <asset.zip> --repo Goopil/php-rabbit-rs \
--predicate-type https://slsa.dev/provenance/v1
Each release therefore contains 108 assets: 36 ZIPs, 36 SHA256 files, and 36 SBOM files, plus 72 attestations (provenance + SBOM) stored in the attestations API (not listed as release assets).
- Static linking — Rust dependencies and TLS libraries are linked statically into the
.sowhenever possible; the only expected system dependency is libc (glibc or musl). No Rust, OpenSSL, or other runtime library is needed on production systems. - Minimum glibc 2.31 — covers Debian 11 and later, Ubuntu 20.04 and later, CentOS 9 Stream and later. Alpine is not affected (uses musl). On an older glibc, use the musl build or compile from source.
- No debug builds — every release artifact is a release-optimized build, ensuring consistent performance and avoiding debug-assertion overhead.
Releases follow a strict order to ensure version coherence:
- CI builds all 36 PIE artifacts — each tested with the target PHP, checksum verified
- Laravel package is split — the monorepo's
packages/laravel-queue/is split into theGoopil/rabbit-rs-laravelmirror repository viascripts/split-laravel-package.sh - Native extension is tagged on Packagist —
goopil/rabbit-rs-nativeappears as a PIE package - Laravel package is tagged on Packagist —
goopil/rabbit-rs-laravelappears as a Composer package - GitHub release is published — only after all binaries are produced, the Laravel tag is pushed, and both Packagist metadata entries are verified
The validation script scripts/validate-distribution.sh checks: root package name and type (goopil/rabbit-rs-native, php-ext), extension name, download method, thread-safety flags, dual Linux/darwin OS family, Laravel package name and namespace, version coherence between Cargo and both packages, the PIE matrix entries with unique suffixes, the minimum glibc version, the PIE asset naming convention across the packaging script, the release workflow and this document, and — when archives are present — the per-cell ZIP + SHA-256 + SBOM inventory with verified checksums and CycloneDX SBOMs. The script predates the widened release matrix (PHP 8.2/8.3, ZTS) and still asserts the V1 NTS-only matrix; it is pending the matching update.
| Channel | Status | Alternative |
|---|---|---|
| PECL | Not supported | Use pie install goopil/rabbit-rs-native |
| Debian packages (apt) | Not maintained | Use PIE in your Dockerfile |
| RPM packages (dnf/yum) | Not maintained | Use PIE in your Dockerfile |
| APK packages (Alpine) | Not maintained | Use PIE (musl binary works on Alpine) |
| Composer plugins installing binaries | Not used | PIE handles binaries, Composer handles PHP source |
| Full PHP images bundling the extension | Not provided | Install PIE in your own Dockerfile |
This is a deliberate design decision: PIE is the PHP ecosystem's official extension installer and handles the binary dimension correctly (version matching, architecture selection, activation). Keeping binary distribution in PIE and source distribution in Composer maintains a clean separation of concerns.