echo-laravel maintained by rhythmsh
rhythmsh/echo-laravel
The Laravel connector for Rhythm Echo. Echo captures everything a staging site sends, such as email, SMS, webhooks, Slack alerts and analytics calls, and checks it.
- Seal (the default): nothing leaves the site. Echo answers in place of the real service, with realistic fake responses shaped like the provider's.
- Observe: everything is recorded, then sent for real.
Laravel 11, 12 and 13 on PHP 8.2+.
composer require rhythmsh/echo-laravel # not --dev: staging deploys skip dev packages, and it is inert in production
rhythm-echo up # Echo on 127.0.0.1:8025
php artisan echo:status
The service provider is auto-discovered. With nothing configured, the connector runs in every environment except production and testing.
Configuration
Everything can be set from the environment. If you want the file, run php artisan vendor:publish --tag=rhythm-echo to publish config/rhythm-echo.php.
| Key | Env | Default | |
|---|---|---|---|
enabled |
RHYTHM_ECHO_ENABLED |
unset | Unset means on, except in production and testing. Set it to true to capture in your test suite. |
url |
RHYTHM_ECHO_URL |
http://127.0.0.1:8025 |
Where Echo listens. |
token |
RHYTHM_ECHO_TOKEN |
none | Sent as X-Echo-Token when Echo listens beyond loopback. |
force |
RHYTHM_ECHO_FORCE |
false |
Run even when APP_ENV=production. Only for a production-like box that isn't production. |
mode |
RHYTHM_ECHO_MODE |
seal |
What to assume when Echo can't be reached. seal fails closed and observe lets traffic through. When Echo answers, its own mode decides. |
timeout |
RHYTHM_ECHO_TIMEOUT |
2 |
Seconds to wait for Echo. |
guzzle |
RHYTHM_ECHO_GUZZLE |
true |
Also covers Guzzle clients built by the container, and the Vonage client. See below. |
Production guard. When app()->environment('production'), the connector does nothing unless force is set. php artisan echo:status says why it's off.
Testing. The app's own test suite keeps its Mail::fake() and Http::fake() untouched, because testing is off by default.
What it covers
| Path | How | Covered |
|---|---|---|
Mail: Mailables, Mail::raw, mail notifications, queued mail, every mailer, Mail::build, failover and round-robin |
Every mailer's transport is wrapped (EchoTransport), so the capture happens under the Mailer, on the finished message |
yes |
Http:: facade, including Http::pool, and packages built on it (such as the Slack Web API notification channel) |
Http::globalMiddleware |
yes |
Guzzle clients the container builds (type-hinted GuzzleHttp\Client, such as the Slack webhook notification channel) |
the container's GuzzleHttp\Client binding gets the same middleware |
yes, when guzzle is on |
| Vonage SMS notifications | vonage.http_client points at the container's Guzzle client, unless you set your own |
yes, when guzzle is on |
new GuzzleHttp\Client(), raw cURL, SDKs with their own HTTP stack (the Twilio and Stripe PHP SDKs, AWS SDK) |
not hooked | no: use Echo's proxy, see below |
Captures. An email capture carries from, to, cc, bcc, reply-to, subject, text, HTML, headers and the full MIME, with the mailer name in source.plugin. An HTTP capture carries the method, URL, headers and the first 256 KiB of the body, with the calling package in source.plugin when one can be found. Credential headers (Authorization, Cookie, X-Api-Key and others) are masked before anything leaves the app. Echo redacts secrets in bodies.
HTTP decisions. Echo answers each request before it's sent:
fake: the app gets Echo's response, and nothing is sent.block: the request fails with Laravel'sConnectionException, as if the network were down.allow: the request is sent, and the response is reported afterwards. Echo always allows the site's own host, loopback, and the hosts in its allowlist.
Mail decisions. Sealed, Echo keeps the message and the app sees a normal send. Observed, the real transport sends it, and the connector then reports the outcome against the same message: sent, or failed with the transport's error (a dead SMTP server, a rejected API key). The exception still reaches the app as usual.
Echo unreachable. With mode set to seal, mail fails with a TransportException and HTTP calls fail with a ConnectionException. Nothing leaves. The array and log mailers keep working, because they never leave the machine. With mode set to observe, traffic goes out normally.
No Http::preventStrayRequests(). The seal doesn't use it, and you don't need to call it on staging. It throws on every request that isn't faked, including the site's own host and the hosts you allow. Echo already decides per host and answers sealed requests with provider-shaped fakes, which is what keeps code paths like webhook retry counters working.
Why the transport and not only MessageSending? A MessageSending listener that returns false does cancel the send. But Laravel dispatches that event with until(), which stops at the first listener that returns anything other than null. One other listener returning true would let the mail out. The transport wrapper can't be skipped like that. The MessageSending listener is only a fallback for apps that bind their own mail manager.
Clients the connector can't see
For SDKs and raw clients, route them through Echo's HTTP proxy:
HTTPS_PROXY=http://127.0.0.1:8026 HTTP_PROXY=http://127.0.0.1:8026 php artisan queue:work
Then trust Echo's CA certificate in PHP's openssl.cafile or curl.cainfo. The connector's own calls to Echo always bypass the proxy.
As a fallback that needs no package at all, point the mailer at Echo's SMTP sink: MAIL_MAILER=smtp MAIL_HOST=127.0.0.1 MAIL_PORT=1025. Run php artisan config:clear after changing it.
Runs and actions
Echo ties every message to the action that caused it, such as "Contact form on /contact/". Soundcheck and rhythm echo run tag each request with X-Echo-Run: <run>/<action>, as a header or a cookie. The connector finds the run in this order:
- the
X-Echo-Runheader on the incoming request - the
X-Echo-Runcookie - the Context a queued job carried over from that request
- the
RHYTHM_ECHO_RUNenvironment variable, which Echo sets on the artisan commands it runs
A global middleware stores the run as a hidden Context value, so it never appears in log lines. Laravel serialises Context into every job queued during the request and restores it in the worker. Queued mail, queued notifications and jobs therefore keep the run without any change to your code.
Queues
Queued mail and jobs are sent when the worker runs them. For staging checks, either:
- set
QUEUE_CONNECTION=sync, so everything is sent during the request, or - leave the queue on and let Echo flush it after each action with
php artisan queue:work --stop-when-empty. This is the Laravel flush command Echo runs.
Commands
php artisan echo:status [--json]: whether the connector is active (and why not), Echo's URL and mode, and a ping of/v1/health. It exits non-zero when the connector is off or Echo can't be reached.php artisan echo:canary [--json]: sends one mail tocanary@echo.invalidand onePOST https://canary.echo.invalid/through the normal paths (the default mailer and theHttpfacade).rhythm echo doctorchecks that both arrive. The.invalidaddresses can't be delivered even when the site is unsealed.
Development
make install # composer:2 in Docker
make check # php -l, Larastan level 6, PHPUnit + Testbench (fake Echo on PHP's built-in server)
make e2e # a fresh Laravel app against Echo's real catcher (needs Go, Docker, jq)
License
MIT