robertboes / patchbay
Dynamic Laravel Reverb applications. Manage your WebSocket apps in the database and reload them without restarting the server.
Requires
- php: ^8.2
- illuminate/console: ^12.0 || ^13.0
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/database: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
- laravel/prompts: ^0.3.0
- laravel/reverb: ^1.9
- spatie/laravel-package-tools: ^1.92.7
Requires (Dev)
- filament/filament: ^4.0 || ^5.0
- laravel/octane: ^2.0
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0 || ^11.0
- phpunit/phpunit: ^11.0 || ^12.0 || ^13.0
Suggests
- filament/filament: Required to use the Patchbay dashboard for managing Reverb applications.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-29 20:51:31 UTC
README
Dynamic Laravel Reverb applications. Keep your WebSocket apps in the database, manage them at runtime, and let a running server pick up changes without dropping a single connection.
Reverb reads its applications from a static config array. That is fine for one app and
awkward for anything else: adding a tenant means a deploy, and changing one means
restarting the server, which disconnects every client of every other app. Reverb's
ApplicationManager is an Illuminate Manager, so the set of applications is a driver
like any other. Patchbay is that driver.
composer require robertboes/patchbay
php artisan patchbay:install
php artisan migrate
Then point Reverb at it:
REVERB_PROVIDER=patchbay
And create an application:
php artisan patchbay:create-app
It asks for a name, the origins allowed to connect, and whether to activate it, then
prints the .env block for the consuming application. Pass any of them to skip the
question, which is also how you script it:
php artisan patchbay:create-app checkout --origins=example.com --no-interaction
Origins are hostnames, example.com or *.example.com: Reverb matches them against the
host of a connection's Origin header alone. One written as a URL is reduced to its
host rather than refusing every client.
An application that restricts its origins also admits the dashboard's own host, so the
debug console can reach it without anyone listing it. That host is PATCHBAY_PANEL_ORIGIN,
or the host of APP_URL when it is not set.
How it works
Three pieces, each doing one thing:
A source owns the data. AppSource is the slow, authoritative side — it is read when
the server boots and when an application actually changes, and never on the connection
path. The default reads from the database with Eloquent. Point patchbay.source at your
own implementation to load applications from somewhere else.
A registry answers lookups. Reverb's ApplicationProvider::findByKey() returns an
Application, not a promise, so a lookup cannot await I/O — and anything it does
synchronously blocks the event loop and therefore every connection on the server. The
registry keeps applications in memory so that lookup is an array read. At roughly 600
bytes per application that is about 6MB for 10,000 apps, an order of magnitude less than
the connections serving them.
A reload driver keeps the two in step. When an application changes, the driver carries which application changed to the running server, which reloads that one. The difference matters: a full reload of 100,000 applications blocks the event loop for about 300ms, where reloading a single application is an indexed query at around 60 microseconds.
The default cache driver needs no extra infrastructure — it is the same shape as
Reverb's own reverb:restart, which polls a cache key on a five second timer. It keeps a
bounded log of recent changes, and if the server ever falls further behind than that log
it reloads everything rather than replaying a partial one, so a dropped signal is a slow
refresh instead of a server quietly serving stale credentials.
Where the registry lives
Patchbay runs in two very different processes and tunes itself to both:
- Inside the Reverb server the registry is filled at boot and kept current by the reload driver. Every lookup is memory; nothing touches the database per connection.
- Inside a web request the registry starts empty, so a lookup falls through to a single indexed query and memoises it for the rest of the request. Under Octane it is dropped between requests, because a long-lived container would otherwise keep serving applications that have since changed.
Dashboard
Patchbay ships a Filament panel. Register the plugin on a panel and applications become manageable there:
use RobertBoes\Patchbay\Filament\PatchbayPlugin; $panel->plugin(PatchbayPlugin::make());
Filament is not a dependency of this package — nothing in src/Filament is reachable
until an application registers the plugin, at which point Filament is necessarily
installed. Filament 4 and 5 are both supported; their resource APIs are identical.
The application page shows live connection counts read from the running server, with
each open channel and how many are on it — people for presence channels, sockets for
the rest. It reveals the secret on request, and hands out what a client needs to
connect: a Laravel .env, a pusher-js browser client and a Pusher server SDK snippet,
since any Pusher client works. Below that, the application's recorded traffic, a log of
the events it carried, and a debug console that sends an event to a channel through the
server's HTTP API and shows it arriving over a live connection, private and presence
channels included — the panel signs those subscriptions with the secret it already
holds.
Deactivating an application from here disconnects its clients, and a single user's connections can be closed without touching anyone else's. That ends a session rather than barring anyone: the client is free to reconnect, and deactivating is what keeps them out.
To bound the connection limit an application may be given, and show it on the form, pass a callback. It receives the application being edited, or null when one is being created; null back means unlimited:
$panel->plugin( PatchbayPlugin::make()->connectionLimit(fn (?Model $app) => auth()->user()->connectionLimit()), );
Widgets are not added to your dashboard unless you ask, since a dashboard is your page and not a package's:
$panel->plugin(PatchbayPlugin::make()->widgets());
Pass a list to choose which. They also appear above the application list either way.
->withoutResource() leaves the built-in resource out, to register your own.
Filament grants panel access automatically only in the
localenvironment. In any other environment yourUsermodel needs to implementFilament\Models\Contracts\FilamentUser, or the panel returns 403 — this is Filament's behaviour, not Patchbay's.
Metrics
Patchbay records what each application is doing from inside the running server, on a timer. Connection and channel counts are already in memory there, so a sample costs nothing and does not depend on the server being reachable from wherever the dashboard runs. Message throughput is recorded the same way and is not available any other way — Reverb's HTTP API does not report it.
Messages are counted in memory and written once per interval, so the database never sits on the path of an individual frame.
php artisan patchbay:prune-metrics
Schedule that alongside your other pruning. Samples older than
patchbay.metrics.retain_days are removed; one application recorded every minute
produces about 43,000 rows a month. Set PATCHBAY_METRICS_ENABLED=false to record
nothing.
The event log
Counts answer how much went through. The log answers what, which is the question you have when something did not arrive.
Each event an application carried is kept with its name, channel and payload, recorded
in the server beside the metrics and written in one statement rather than one per
frame. The buffer is capped by patchbay.events.buffer, so a burst costs a bounded
amount of memory and drops the oldest rather than growing without end.
php artisan patchbay:prune-events
Schedule that more often than the metrics pruning: a busy server writes a row per
message, so the default one day of retention arrives a good deal faster than a day of
samples. PATCHBAY_EVENTS_ENABLED=false records nothing.
Payloads are whatever your application happens to send. They are cut to
PATCHBAY_EVENT_PAYLOAD_LENGTH characters, and PATCHBAY_EVENT_PAYLOADS=false keeps
the names and channels without the contents.
What is not recorded. Connecting, subscribing and staying alive are what the
protocol costs, not traffic an application produced — pusher:ping, pusher:subscribe,
pusher_internal:subscription_succeeded and the rest are left out of both the log and
the counts. A connection that has carried nothing reports nothing.
Live figures
The panel connects to the server like any other client, so figures follow the server rather than a timer. That needs an application of its own, which Patchbay holds in memory rather than storing: it has no row, no quota and no place in any listing, and it is left out of traffic figures and of the count that decides whether a server is degraded — watching must not look like serving.
Its key and secret are derived from your APP_KEY, so every process that has the key
agrees on them without coordinating, and rotating the key rotates these with it.
A reading is pushed only when it differs from the last one, so a panel left open on an
idle server costs nothing. PATCHBAY_INTERNAL_INTERVAL decides how often the server
looks; it is deliberately separate from the metrics interval, which decides how much is
stored rather than how fresh the screen is.
One server. The panel holds one connection to one server, so it sees that server's readings. Metrics are recorded per server and the stored figures still cover a fleet; the live ones do not, unless Reverb's scaling is enabled so that a push reaches every server's clients.
PATCHBAY_INTERNAL_APP=falseturns this off and the panel falls back to asking on a timer.
Seeing what the server is doing
php artisan patchbay:status
A server can answer HTTP while holding no applications, refusing every connection. So
the running server also writes a heartbeat to the shared cache every ten seconds,
saying how many applications it serves, and HealthCheck combines the two:
app(\RobertBoes\Patchbay\Server\HealthCheck::class)->status(); // Health::Operational, Degraded or Down
Degraded means answering but not reporting in, or serving none of the active
applications. The status widget and patchbay:status both use it.
Connection counts and open channels live only in the running server's memory, not in the database, so Patchbay asks it over Reverb's HTTP API. The address is worked out from your reverb config and normally needs no configuration at all.
Two things about that are worth knowing, because Reverb's settings do not mean what their names suggest:
REVERB_SERVER_HOST is the interface the server listens on, while REVERB_HOST is
the name it is reached by. Setting REVERB_HOST does not move the server, but
Reverb looks for a Valet or Herd certificate matching that name and serves TLS if it
finds one. Patchbay asks the same question, so it knows to use https and to address
the server by the name on the certificate rather than by its bind address.
Set PATCHBAY_SERVER_URL when the server is somewhere the config cannot describe, such
as another container.
Every call is bounded by a timeout and cached for a few seconds, so a server that has stopped responding cannot turn a dashboard render into a queue of hanging requests. Results distinguish "no connections" from "could not reach the server" rather than reporting zero for both.
Configuration
Everything has a working default, so the package functions before you publish anything.
config/patchbay.php covers the source, the reload driver and its interval, per-app
defaults, and the connection options handed to clients.
Patchbay does not replace your Reverb config. You run reverb:start exactly as
before; Patchbay only supplies the applications. Anything Reverb already decides —
where it binds, which port, whether it serves TLS — is read from config/reverb.php
rather than restated here.
Per-application defaults are the settings Reverb defines on an application —
ping_interval, activity_timeout, max_message_size, max_connections,
allowed_origins, accept_client_events_from and rate_limiting — and they read the
same REVERB_APP_* environment variables Reverb reads, so an existing .env keeps
working. Switching provider empties Reverb's apps.apps array; it does not change
what an application means.
The cache store must be shared between processes. The control panel and the Reverb server are different processes, so the
arraystore cannot carry changes between them.fileanddatabaseboth work on a single host; useredisormemcachedwhen the panel and the server run on different machines. SetPATCHBAY_CACHE_STOREto pick a store other than the application default.
Revoking an application
Deactivating or deleting an application disconnects the clients it still has open, not just the ones that try to connect afterwards. An open connection was authenticated when it was established, so without this it would keep working indefinitely.
Reverb tracks connections through channels and keeps no separate list of them, so this reaches every connection subscribed to at least one channel — the same set Reverb's own shutdown disconnects. A client that has connected but subscribed to nothing is not enumerable and survives until it subscribes or its activity timeout expires.
Set patchbay.terminate_on_revoke to false to leave open connections alone.
Secrets
An application's key is public and travels to the browser. Its secret signs Pusher
requests and never leaves the server, so it is stored encrypted rather than hashed —
HMAC signing needs the original value back. The model hides it by default; the
patchbay:create-app command prints it once.
Rotating APP_KEY re-encrypts nothing on its own. If you rotate it, re-encrypt the
secret column in the same deploy or every application will stop authenticating.
Requirements
- PHP 8.2+
- Laravel 12 or 13
- Reverb 1.9+
Reverb's Application constructor gained parameters in 1.6, 1.8 and 1.9, each inserted
before $options. Patchbay passes every argument by name and requires 1.9 so that the
full set of per-application settings is available on a single code path. If you need
support for an earlier Reverb, open an issue.
Testing
composer test
License
MIT. See LICENSE.md.