PHPackages                             kirschbaum-development/pest-plugin-realtime - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Testing &amp; Quality](/categories/testing)
4. /
5. kirschbaum-development/pest-plugin-realtime

ActiveLibrary[Testing &amp; Quality](/categories/testing)

kirschbaum-development/pest-plugin-realtime
===========================================

Deterministically test realtime browser behavior, dropped events, and connection recovery with Pest.

v0.7.1(today)10[9 issues](https://github.com/kirschbaum-development/pest-plugin-realtime/issues)MITPHPPHP ^8.3CI passing

Since Jul 15Pushed todayCompare

[ Source](https://github.com/kirschbaum-development/pest-plugin-realtime)[ Packagist](https://packagist.org/packages/kirschbaum-development/pest-plugin-realtime)[ Docs](https://github.com/kirschbaum-development/pest-plugin-realtime)[ RSS](/packages/kirschbaum-development-pest-plugin-realtime/feed)WikiDiscussions main Synced today

READMEChangelog (8)Dependencies (11)Versions (11)Used By (0)

Pest Plugin Realtime
====================

[](#pest-plugin-realtime)

[![Tests](https://github.com/kirschbaum-development/pest-plugin-realtime/actions/workflows/tests.yml/badge.svg)](https://github.com/kirschbaum-development/pest-plugin-realtime/actions/workflows/tests.yml)[![Static Analysis](https://github.com/kirschbaum-development/pest-plugin-realtime/actions/workflows/static.yml/badge.svg)](https://github.com/kirschbaum-development/pest-plugin-realtime/actions/workflows/static.yml)

Deterministically test realtime browser behavior, dropped events, and connection recovery with [Pest](https://pestphp.com).

The first driver targets Laravel Echo with its Pusher-compatible connector, including Reverb, Pusher, and Ably's Pusher protocol. It operates at the existing Echo subscription boundary, so no realtime server is required during browser tests.

The integration is tested in a real browser against Laravel Echo 2.x and pusher-js 8.x. The package does not install those JavaScript libraries in consuming applications; it uses the application's existing client.

Requirements
------------

[](#requirements)

- PHP 8.3+
- Pest 4 or 5
- Pest Browser 4 or 5
- A page using Laravel Echo's Pusher-compatible connector

Installation
------------

[](#installation)

```
composer require kirschbaum-development/pest-plugin-realtime
```

Your browser-test frontend must create its normal Echo subscriptions. It can point to a closed local port because the simulator stops the real client after the page loads.

```
VITE_BROADCAST_CONNECTION=reverb
VITE_REVERB_APP_KEY=browser-tests
VITE_REVERB_HOST=127.0.0.1
VITE_REVERB_PORT=65535
VITE_REVERB_SCHEME=http
```

Backend broadcasting can remain disabled. The session temporarily replaces Laravel's configured broadcast connections and restores them afterward.

Usage
-----

[](#usage)

```
use App\Models\Auction;
use Illuminate\Broadcasting\PrivateChannel;

use function Pest\Realtime\broadcasting;

it('recovers an event missed while disconnected', function (): void {
    $auction = Auction::query()->findOrFail(1);
    $page = visit("/auctions/{$auction->id}/live");

    $broadcasting = broadcasting($page)
        ->assertSubscribed('auctions.1')
        ->assertSubscribed(new PrivateChannel('buyers.2'));

    $auction->update(['lot_selling_price' => 2200]);

    $broadcasting->assertDelivered('lot.price.changed');
    $page->assertSee('$2,200.00');

    $broadcasting->disconnect();

    $auction->update(['lot_selling_price' => 3300]);

    $broadcasting->assertDropped('lot.price.changed');
    $broadcasting->fail()->reconnect();

    $page->assertSee('$3,300.00');
});
```

There is no setup step. The browser runtime installs itself on the first realtime call, the simulated client starts connected, and inside a booted Laravel application the session captures the app's broadcasts and replays them into the page as they happen.

Model observers, event listeners, `broadcastWhen()`, `broadcastOn()`, `broadcastAs()`, `broadcastWith()`, and explicitly selected broadcast connections all run under Laravel's normal dispatcher. The plugin captures the final calls Laravel would make to its broadcaster.

```
test callback
    │
    ▼
application code ──► observer/listener ──► Laravel broadcast event
                                              │
                                              ▼
                                    temporary capture driver
                                              │ final channels,
                                              │ name, payload
                                              ▼
                                Echo simulator ──► page listener

```

`ShouldBroadcast` events are run inline: the queue connection is switched to `sync` while capturing, and restored afterward. Broadcast jobs handled later by a separate queue worker run in another process and stay outside this in-memory capture scope.

Do not wrap the application event under test in `Event::fake()`. Laravel cannot run observers or broadcast listeners for a suppressed event, so the session refuses to start and tells you why.

### Broadcasts from the page's own requests

[](#broadcasts-from-the-pages-own-requests)

Pest Browser serves page requests in a separate Fiber. Replaying a broadcast from inside one would re-enter the browser while the page is still waiting for its response, so those broadcasts are held and replayed the next time the test reads the session:

```
$page->click('Place bid');

$broadcasting->assertDelivered('lot.price.changed'); // flushes, then asserts
$page->assertSee('$2,200.00');
```

Every realtime read flushes first, so assertions, `channels()`, `status()`, and `capture()` all pick them up. When a page assertion is the next thing that needs them, flush explicitly:

```
$page->click('Place bid');

$broadcasting->flush();

$page->assertSee('$2,200.00');
```

### Assertions

[](#assertions)

```
$broadcasting->assertDelivered('lot.price.changed');
$broadcasting->assertDelivered(LotPriceChanged::class);
$broadcasting->assertDelivered('lot.price.changed', 2);
$broadcasting->assertDeliveredTimes('lot.price.changed', 2);
$broadcasting->assertDeliveredOn(new PrivateChannel('buyers.2'), 'lot.price.changed');
$broadcasting->assertNotDelivered('lot.price.changed');
$broadcasting->assertDropped('lot.price.changed');
$broadcasting->assertNothingDropped();
$broadcasting->assertBroadcast('lot.price.changed');
$broadcasting->assertNotBroadcast('lot.price.changed');
$broadcasting->assertNothingBroadcast();
$broadcasting->assertConnected();
$broadcasting->assertDisconnected();
$broadcasting->assertSubscribed('auctions.1');
$broadcasting->assertNotSubscribed(new PrivateChannel('admins.1'));
$broadcasting->assertDeliveredInOrder(['lot.opened', 'lot.price.changed', 'lot.closed']);
$broadcasting->assertDeliveredVia('reverb', 'lot.price.changed');
$broadcasting->assertNotDeliveredVia('pusher', 'lot.price.changed');
```

Every assertion returns the session, so they chain. Failures name the event and list what actually happened:

```
The expected [lot.price.changed] broadcast was not dropped.
Broadcasts sent: lot.price.changed on [auctions.1] (delivered), order.updated on [private-buyers.2] (not_subscribed).

```

A class string matches the wire name `broadcastAs()` would have produced, so both `LotPriceChanged::class` and `'lot.price.changed'` work.

A closure narrows the match. It receives the `CapturedBroadcast`, since Laravel hands a broadcaster only the wire name and payload:

```
use Pest\Realtime\CapturedBroadcast;

$broadcasting->assertDelivered(
    'lot.price.changed',
    fn (CapturedBroadcast $broadcast): bool => $broadcast->payload['lot_selling_price'] === 2200,
);
```

An array is the shorthand for the common case, matching the payload as a subset:

```
$broadcasting->assertDelivered('lot.price.changed', ['lot_selling_price' => 2200]);
```

`assertDeliveredInOrder()` checks relative order, so unrelated broadcasts may arrive in between.

### Channels

[](#channels)

Channels accept Laravel's own vocabulary, an Eloquent model, a bare name, or the wire identifier you see in devtools:

```
$broadcasting->assertSubscribed('auctions.1');
$broadcasting->assertSubscribed(new PrivateChannel('buyers.2'));
$broadcasting->assertSubscribed(new PresenceChannel('room.3'));
$broadcasting->assertSubscribed(new EncryptedPrivateChannel('vault.5'));
$broadcasting->assertSubscribed('private-buyers.2');
$broadcasting->assertSubscribed($post);  // private-App.Models.Post.1
```

A model resolves to the private channel Laravel's own conventions produce, which is what `BroadcastsEvents` and broadcast notifications use. That makes model broadcasting read directly:

```
$post->update(['title' => 'Revised']);

$broadcasting->assertDeliveredOn($post, 'PostUpdated');
```

`assertSubscribed()` waits for late subscriptions. The default timeout is Pest Browser's own assertion timeout, and can be overridden per call with `assertSubscribed(..., timeoutMilliseconds: 10_000)`.

Encrypted private channels are delivered to directly rather than through pusher-js's decryption path, since the simulator has no shared secret to encrypt with. The page's listener runs exactly as it would for a decrypted frame.

### Scoped capture

[](#scoped-capture)

When you want the broadcasts from one specific action rather than the whole test:

```
$broadcasts = $broadcasting->capture(
    fn () => $auction->update(['lot_selling_price' => 2200]),
);

$broadcasts->assertDelivered('lot.price.changed')->assertNothingDropped();

$broadcasts->capturedCount();
$broadcasts->deliveredCount();
$broadcasts->droppedCount();
$broadcasts->notSubscribedCount();
$broadcasts->excludedCount();
$broadcasts->allDelivered();

$broadcasts->broadcasts();  // Collection
$broadcasts->deliveries();  // Collection
```

`$broadcasting->captured()` returns the same object for everything the session has sent.

Each `CapturedBroadcast` exposes its wire `channels`, `event`, and `payload`, along with the selected Laravel `connection` and any `socket` exclusion supplied by `toOthers()`. Each `Delivery` links that capture to a normalized channel, its visibility, and an outcome:

- `Delivered`: the page registered the channel and its simulated connection was connected.
- `Dropped`: the page registered the channel but its simulated connection was not connected.
- `NotSubscribed`: this page did not register the channel. This is normal when an event broadcasts to several page types; replay continues to the remaining channels.
- `Excluded`: the broadcast excluded this page's socket through `toOthers()`.

### Emitting directly

[](#emitting-directly)

`broadcast()` pushes a Laravel event, deriving channels, name, and payload from `broadcastOn()`, `broadcastAs()`, and `broadcastWith()`:

```
$broadcasting->broadcast(new LotPriceChanged($auction));
```

`emit()` pushes a raw event at the wire boundary, for synthetic and malformed-payload tests:

```
$broadcasting->emit('lot.price.changed', 'auctions.1', ['lot_selling_price' => 2200]);
```

Neither dispatches through Laravel, so neither evaluates `broadcastWhen()`. Let the application dispatch the event when its conditional broadcasting behavior is part of the test.

Anonymous events need nothing special. `Broadcast::on(...)->as(...)->with(...)->send()` is an ordinary `ShouldBroadcast` event, so capture records it like any other.

### Presence channels

[](#presence-channels)

Membership is driven from the test, so `here()`, `joining()`, and `leaving()` in the page run against a roster you control:

```
$broadcasting
    ->here(new PresenceChannel('room.3'), [
        ['id' => 1, 'name' => 'Ana'],
    ])
    ->joining(new PresenceChannel('room.3'), ['id' => 2, 'name' => 'Bo'])
    ->leaving(new PresenceChannel('room.3'), 2);

$broadcasting->assertMemberCount(new PresenceChannel('room.3'), 1);
$broadcasting->assertMember(new PresenceChannel('room.3'), 1);
$broadcasting->assertNotMember(new PresenceChannel('room.3'), 2);

$broadcasting->members(new PresenceChannel('room.3')); // [1 => ['id' => 1, 'name' => 'Ana']]
```

The member array is the one a presence channel authorization callback returns, and its `id` becomes the member id unless you pass one. A bare name is treated as a presence channel here, so `here('room.3', ...)` also works.

### Client events

[](#client-events)

`whisper()` pushes a client event into the page, as another client's whisper would:

```
$broadcasting->whisper('typing', new PrivateChannel('chat.1'), ['name' => 'Ana']);
```

Whispers the page itself sends are recorded, so a typing indicator can be asserted from the other side:

```
$page->fill('#message', 'Hello');

$broadcasting->assertWhispered('typing');
$broadcasting->assertNotWhispered('resize');

$broadcasting->whispers(); // Collection
```

Each `Whisper` exposes its `event`, wire `channel`, `payload`, and whether the simulated connection was `connected` at the time.

### Notifications

[](#notifications)

Broadcast notifications travel the same capture path, and assertions take the notifiable directly:

```
$user->notify(new OrderShipped($order));

$broadcasting->assertNotified($user, OrderShipped::class);
$broadcasting->assertNotNotified($user, OrderCancelled::class);
```

A notifiable resolves to its private model channel. Pass a channel explicitly when the notifiable overrides `receivesBroadcastNotificationsOn()`. As with `Event::fake()`, do not wrap the notification under test in `Notification::fake()`; the session refuses to start and tells you why.

### Failed subscriptions

[](#failed-subscriptions)

Channel authorization runs against your application's own endpoint and is out of the simulator's scope, but the client-side outcome of a refusal is not:

```
$broadcasting->failSubscription(new PrivateChannel('orders.1'), status: 403);

$page->assertSee('You no longer have access to this order.');
```

That fires Echo's `error()` callback with the same shape Pusher produces for a denied authorization.

### Connection controls

[](#connection-controls)

```
$broadcasting->connect();
$broadcasting->disconnect();
$broadcasting->fail();
$broadcasting->unavailable();
$broadcasting->reconnect(); // connecting, then connected
$broadcasting->transitionTo(ConnectionStatus::Unavailable);
$broadcasting->status();
```

The Echo/Pusher driver models Pusher's `initialized`, `connecting`, `connected`, `unavailable`, `failed`, and `disconnected` states. Every transition emits both Pusher's `state_change` event and the state-specific event, matching the real client's observable behavior. Echo normalizes Pusher's `unavailable` state to `failed` through `Echo.connectionStatus()`.

### Testing `toOthers()`

[](#testing-toothers)

The simulated client's socket id is available, so a broadcast that excludes it can be exercised end to end:

```
Broadcast::socket($broadcasting->socketId());

$auction->update(['lot_selling_price' => 2200]);

$broadcasting->assertNotDelivered('lot.price.changed');
```

Capture runs in the test's PHP process, so `$socket` is only set when the test sets it. Requests the browser itself makes carry their own `X-Socket-ID` and are handled in another process.

### Defaults

[](#defaults)

Set once in `tests/Pest.php`:

```
use Pest\Realtime\Realtime;

Realtime::driver(new YourRealtimeDriver());
Realtime::timeout(10_000);
```

What it tests
-------------

[](#what-it-tests)

```
Pest test ──► simulator ──► Echo/Pusher channel ──► application listener

```

- Public, private, presence, and encrypted private subscriptions
- Exact event names and payloads
- Initialized, connecting, connected, unavailable, failed, and disconnected states
- Events delivered while connected
- Events dropped during an outage
- Application resync behavior after recovery
- Presence rosters and join/leave churn
- Client events in both directions
- Broadcast notifications
- The client-side outcome of a refused subscription

Boundary and limitations
------------------------

[](#boundary-and-limitations)

This package is a realtime client simulator, not a WebSocket protocol emulator. It installs after navigation and deliberately centralizes version-sensitive access to Echo/Pusher's active client and channels.

It does not test:

- WebSocket handshakes or frames
- Reverb/Pusher server behavior
- TLS, proxy, or load-balancer configuration
- Channel authorization endpoints, only what the client does when one refuses

One session captures one application at a time. Starting a second session against the same Laravel application throws; call `stopCapturing()` on the first, or let it go out of scope. Multi-client scenarios that need two pages sharing one capture are not supported yet.

An event implementing `ShouldDispatchAfterCommit` is not dispatched until its transaction commits, which a transactional test never does. When nothing was captured and a transaction is open, the failure message says so.

Keep backend tests for channel authorization, event payload contracts, and broadcast failure tolerance. A future driver can use Playwright WebSocket routing when Pest Browser exposes that browser-context API publicly.

Custom drivers
--------------

[](#custom-drivers)

Implement `Pest\Realtime\Contracts\Driver` and pass it to `broadcasting()`, or register it as the default:

```
$broadcasting = broadcasting($page, new YourRealtimeDriver());
```

`Driver` gained `presenceScript()`, `membersScript()`, `clientEventsScript()`, and `subscriptionErrorScript()` in 0.7.0, and `BroadcastCapture` gained `drainPending()` and `hint()`. Drivers and capture implementations written against 0.6 need those methods added.

License
-------

[](#license)

Pest Plugin Realtime is open-source software licensed under the [MIT license](LICENSE.md).

###  Health Score

35

—

LowBetter than 77% of packages

Maintenance80

Actively maintained with recent releases

Popularity2

Limited adoption so far

Community8

Small or concentrated contributor base

Maturity45

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 93.8% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~1 days

Total

10

Last Release

0d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/57e405b52d482c9de35b17f299d2745e8e68d9d9951aec64854f2d7fa53110bf?d=identicon)[luisdalmolin](/maintainers/luisdalmolin)

---

Top Contributors

[![luisdalmolin](https://avatars.githubusercontent.com/u/403446?v=4)](https://github.com/luisdalmolin "luisdalmolin (15 commits)")[![dependabot[bot]](https://avatars.githubusercontent.com/in/29110?v=4)](https://github.com/dependabot[bot] "dependabot[bot] (1 commits)")

---

Tags

browser-testinglaravel-echopestpest-pluginrealtime-testingwebsockettestingpestbrowserlaravelwebsocketpusherrealtimeecho

### Embed Badge

![Health badge](/badges/kirschbaum-development-pest-plugin-realtime/health.svg)

```
[![Health](https://phpackages.com/badges/kirschbaum-development-pest-plugin-realtime/health.svg)](https://phpackages.com/packages/kirschbaum-development-pest-plugin-realtime)
```

###  Alternatives

[illuminate/database

The Illuminate Database package.

2.8k55.8M12.5k](/packages/illuminate-database)[craftcms/cms

Craft CMS

3.6k3.7M3.3k](/packages/craftcms-cms)[illuminate/notifications

The Illuminate Notifications package.

483.1M1.2k](/packages/illuminate-notifications)[illuminate/testing

The Illuminate Testing package.

3316.8M145](/packages/illuminate-testing)[illuminate/filesystem

The Illuminate Filesystem package.

15265.2M3.4k](/packages/illuminate-filesystem)[illuminate/pagination

The Illuminate Pagination package.

12234.6M1.1k](/packages/illuminate-pagination)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
