Every inbound WebSocket message is handled in a pcntlfork() child process that runs your handler synchronously and then exit(0)s. (src/Websocket/Handler.php, forkWithSocketPair.)
php artisan boost:add-skill blax-software/laravel-websockets
Save this content to: AGENTS.md
# AGENTS.md — laravel-websockets
> Audience: AI coding agents (Claude Code, Copilot, Cursor) and human contributors.
> This is the **canonical, source-verified reference** for *how to use this package*.
> The README is a marketing overview; **this file is the contract**. When the two
> disagree, this file wins (and fix the README).
>
> Every claim below is grounded in `src/`. File\:line citations are given so you
> can verify before you trust. Do **not** answer questions about this package from
> training-data memory — read the cited source.
---
## 0. The one mental model you must hold
**Every inbound WebSocket message is handled in a `pcntl_fork()` child process that
runs your handler synchronously and then `exit(0)`s.** (`src/Websocket/Handler.php`,
`forkWithSocketPair`.)
Consequences — internalize these, they change how you write handlers:
- **Blocking code is SAFE inside a handler.** `sleep()`, `usleep()`, synchronous
`Http::get()`, heavy DB queries — they block only *that one child*, never the
event loop or other clients. You do **not** need ReactPHP promises in a handler.
Write ordinary blocking Laravel code.
- **Laravel `defer()` works** — deferred callbacks are flushed just before the child
persists its session and exits. But see §7: over the WS bridge `defer()` is a
known hazard; prefer a real queued **Job** for anything heavy or that must survive.
- **State does not carry over between messages** except via `wsSession()` (§6) or the
cache. Each child gets a *fresh* DB connection and purged Redis/cache singletons.
- **Each child reseeds `mt_rand`** (`ChildProcesses::reseedRandom()`), so `rand()`,
`array_rand()` and `shuffle()` differ per message. Without it every child continues
the parent's generator state and draws the same numbers.
- **Concurrency is capped** by `config('websockets.max_concurrent_children')` (protects
MySQL `max_connections`); excess messages queue in memory and run as children free up.
- **A child never outlives the server.** Right after fork the child closes its copy of the
listening socket and gets default signal handling back; on shutdown the server SIGTERMs
running children, SIGKILLs them after `websockets.shutdown.child_grace_seconds` (default 2)
and reaps them (`src/Server/ChildProcesses.php`). A handler that blocks for minutes (an
engine loop) is killed on every restart, so keep its state in Redis/DB and let a later
message take over. Opt out with `websockets.shutdown.terminate_children = false`.
- **A child that dies still answers.** A fatal error (out of memory, E_ERROR) sends
`<event>:error` with `meta.fatal = true` from a shutdown function
(`src/Server/ChildFatalErrorGuard.php`); a child that dies with no reply at all (killed by a
signal, or the shutdown function never ran) gets `<event>:error` from the parent
(`src/Websocket/ChildMessageOutcome.php`).
---
## 1. Two ways to expose an endpoint — pick the right one
There are **two controller families**. They do not mix. Choose before you write code.
### A) `#[Websocket]` attribute on a *regular HTTP controller* — `src/Attributes/Websocket.php`
Turn any `App\Http\Controllers\…` method into a WS endpoint with one annotation. The
**same method serves HTTP and WS**. Use this for thin "return data" endpoints that you
also want reachable over REST.
```php
use BlaxSoftware\LaravelWebSockets\Attributes\Websocket;
class AerodromeController extends Controller // a normal HTTP controller
{
#[Websocket] // event: "api-v1-aerodrome.index"
public function index() { return AerodromeService::v1Index(); }
#[Websocket] // args bound BY NAME from payload
public function show(string $icao) { return AerodromeService::v1Show($icao); }
#[Websocket(event: 'user.stats', needAuth: true)] // explicit name + auth gate
public function stats() { return [...]; }
}
```
- **Event name** defaults to `kebab(class path under App\Http\Controllers) + '.' + method`,
e.g. `Api\V1\AerodromeController::index` → `api-v1-aerodrome.index`. Override with
`event:`, `prefix:`, or `suffix:`. Class-level `suffix:` is ignored.
- **`needAuth` defaults to `false`** here. (Constructor: `Websocket.php:36`.)
- Method args are filled **by parameter name** from the payload (`Controller::resolveAttributeMethodArgs`).
`show(string $icao)` receives `$data['icao']`.
- **These methods do NOT get `$this->progress()/success()/error()/broadcast()/whisper()`** —
those live on the *native* base class (family B). An attribute method just `return`s data,
which is auto-wrapped as `…:response`.
- ⚠️ **HTTP middleware is SKIPPED** on this path (`dispatchHttpAttributeTarget`). No
auth/throttle/`ConvertEmptyStringsToNull`/`TrimStrings`. See §4.
### B) Native controller extending `Websocket\Controller` — `src/Websocket/Controller.php`
The richer family. Lives in `app/Websocket/Controllers/<Prefix>Controller.php`. Gives you the
full response API (`$this->progress/success/error/broadcast/whisper`) and `$this->connection`.
```php
namespace App\Websocket\Controllers;
class DashboardController extends \BlaxSoftware\LaravelWebSockets\Websocket\Controller
{
public $need_auth = true; // see §3 — DEFAULT IS TRUE when omitted
public function index() // reachable at 'dashboard.index'
{
$user = auth()->user(); // §4: NOT request()->user()
$locale = request('locale') ?: app()->getLocale();
$result = DashboardService::load($user, $locale, function ($section, $data) {
$this->progress(['section' => $section, 'data' => $data]); // stream
});
return $this->success($result); // final ':response'
}
}
```
- **Event name** = `<class-prefix>.<method>`. `SimulatorController::transmit` → `simulator.transmit`.
Resolution: kebab→Pascal + `Controller`, e.g. `admin-user.x` → `AdminUserController`, with
folder fallbacks (`Admin\UserController`). (`src/Websocket/ControllerResolver.php`.)
- **Do NOT add `#[Websocket]`** to these — they are resolved by class name, not the attribute registry.
- **Do NOT override `__construct`** — it's `final`. Use the `boot()` / `booted()` / `unboot()`
lifecycle hooks instead (`Controller.php:35-52`). Returning exactly `false` from `boot()`/`booted()`
aborts.
> **Resolution order:** the native resolver (family B, `app/Websocket/Controllers`) is tried
> **first**; the attribute registry (family A) is the **fallback** when no native controller
> matches. So a native controller prefix *shadows* an attribute endpoint with the same prefix —
> if you expose attribute endpoints under a prefix that also has a native controller, use a
> **distinct prefix**.
---
## 2. Responding to the caller — the `:suffix` wire protocol
When a client calls an event, the server replies on **suffixed** event names. The client library
(`@blax-software/networking`) correlates the reply to the call. You produce these via:
| You write… | Client receives event… | Notes |
|----------------------------------|--------------------------|---------------------------------------------------|
| `return $payload;` (non-bool) | `<event>:response` | **Auto-wrapped.** The normal happy path. |
| `$this->success($payload)` | `<event>:response` | Explicit. Returns `true` (suppresses auto-wrap). |
| `$this->progress($payload)` | `<event>:progress` | Stream 0..N times *before* the final response. |
| `$this->error($msg)` | `<event>:error` | String → `['message' => $msg]`. Returns `true`. |
| `return true;` / `return false;` | *(nothing)* | Sends no frame — use after you already replied. |
| *(uncaught `throw`)* | `<event>:error` | Auto-converted; reported to Sentry (not for `ValidationException`). |
Signatures (verbatim, `src/Websocket/Controller.php:344-434`):
```php
final public function progress(mixed $payload = null, ?string $event = null, ?string $channel = null): bool; // :350
final public function success (mixed $payload = null, ?string $event = null, ?string $channel = null): bool; // :377
final public function error (array|string|null $payload = null, ?string $event = null, ?string $channel = null): bool; // :410
```
The suffix is appended automatically — **never write `…:response` yourself**. `$event` overrides
the base event name; `$channel` defaults to the current channel.
**Canonical streaming pattern** — hand `$this->progress` down into a service as a closure so the
service stays WS-agnostic:
```php
public function transmit()
{
$result = SimulatorConversationService::doSimulation(
fn ($p) => $this->progress($p), // each chunk → 'simulator.transmit:progress'
);
return $result; // final → 'simulator.transmit:response'
}
```
⚠️ **Footguns:**
- **Don't both `return $payload` AND `$this->success($payload)`** — the client gets two
`:response` frames. Pick one.
- `$this->error(...)` returns `true` but does **not** halt the method — `return $this->error(...)`
if you mean it as a guard clause. (Several call sites forget the `return` and keep executing.)
- `progress()`/`success()` deref `$this->channel->getName()` for the default channel — a controller
with no channel context will fault if you omit `$channel`.
---
## 3. Auth gate
| Family | How to gate | Default |
|--------|-------------|---------|
| A — `#[Websocket]` | `needAuth:` attribute arg | **`false`** |
| B — native `Controller` | `public $need_auth = true\|false;` property | **`true`** (when property is absent — `Controller.php:108` reads `?? true`) |
So a native controller with **no** `$need_auth` property requires auth on *every* method. Set
`public $need_auth = false;` for guest/public endpoints. The gate is controller-**wide** for native
controllers (no per-method granularity — use family A for per-method auth). Auth self-heal: a client
may pass `data.authtoken` and the package resolves it via `config('websockets.auth_resolver')` /
Sanctum.
---
## 4. Reading the authed user / request inside a handler — the #1 mistake
> **`request()->user()` is `null` over the WS bridge.** WS handlers run *outside* HTTP middleware,
> so no `userResolver` is installed. Use the **guard**, which the package populates from the socket.
```php
$user = auth()->user(); // ✅ canonical
$user = auth()->guard()->user(); // ✅ equivalent (this is what User::auth() does)
$user = request()->user(); // ❌ null over WS — even for an authenticated connection
```
`request()` itself **is** rebuilt from the message payload, so `request('key')` and
`request()->validate([...])` work exactly like HTTP — only `->user()` is the trap.
**Corollary — blank strings are NOT nulled.** `ConvertEmptyStringsToNull` and `TrimStrings`
middleware do **not** run over the bridge, so an empty form field arrives as `''`, not `null`.
Add explicit `nullable` validation rules and nullify blank inputs yourself, or validation that
expects `null` will reject `''`.
---
## 5. Pushing events from *outside* a handler (jobs, commands, HTTP, services)
There is **no `$this`** in a job/command, so the `$this->broadcast/whisper` methods are unavailable.
Use the **global helpers** (`src/helpers_global.php`, autoloaded via `composer.json` `autoload.files`)
or `WebsocketService`. These send the event name **verbatim — no `:suffix`** (they are server-initiated
fan-out, not request/response).
```php
// Always guard — these no-op (return false) if the server isn't running:
if (! ws_available()) return;
ws_broadcast('chat.message', ['text' => 'Hi'], 'chat'); // → whole channel
ws_whisper ('info:notification', $data, $socketIds, 'websocket'); // → specific sockets
ws_broadcast_except('chat.message', ['text' => 'Hi'], [$senderSocketId], 'chat');
```
Signatures (verbatim, `src/helpers_global.php`):
```php
ws_broadcast(string $event, array $data, string $channel = 'websocket'): bool // :28
ws_whisper(string $event, array $data, array $sockets, string $channel = 'websocket'): bool // :48
ws_broadcast_except(string $event, array $data, array $excludeSockets, string $channel = 'websocket'): bool // :68
ws_client(): BroadcastClient // :86
ws_available(): bool // :98
```
**Whispering takes socket IDs, not user IDs.** Resolve them first:
```php
$sockets = \BlaxSoftware\LaravelWebSockets\Services\WebsocketService::getUserSocketIds($user->id);
if ($sockets) ws_whisper('info:notification', $data, $sockets);
```
`WebsocketService` is the class-based equivalent (`src/Services/WebsocketService.php`) and also exposes
live connection state — `getUserSocketIds()`, `isUserConnected()`, `getAuthedUsers()`,
`getActiveChannels()`, `getChannelConnections()`.
> ⚠️ **`broadcast`/`whisper` argument order differs between the controller methods and the helpers** —
> a real footgun:
> - controller: `$this->whisper($data, $event, $sockets, $channel)` — **data first**
> - helper: `ws_whisper($event, $data, $sockets, $channel)` — **event first**
---
## 6. Per-connection session — `wsSession()`
A Redis-backed key/value store **scoped to one socket**, surviving across messages on that socket
(each child loads → mutates → `save()`s before exit). Only available inside a WS child handler.
```php
wsSession()->increment('transmit_count');
$count = wsSession()->get('transmit_count', 0);
wsSession()->put('last_action', 'transmitted');
if (wsSession()->has('pending')) wsSession()->forget('pending');
```
```php
function wsSession(): ?\BlaxSoftware\LaravelWebSockets\Websocket\ConnectionSession // helpers_global.php:131
```
> ⚠️ **`wsSession()` takes NO arguments.** Older docs showed `wsSession('channel', [...])` returning an
> "auth payload" — that signature is **fictional**; it never existed. `wsSession()` returns a
> `ConnectionSession` store (or `null` outside a WS child — null-guard it).
---
## 7. `defer()` vs Jobs
`defer()` runs work after the response frame is sent (keeps handlers snappy). It *works* over the
bridge, but it is a **documented hazard**: deferred callbacks can accumulate and be re-executed by
later forked children, and they are **never invoked on connection close**. Heavy/durable work is being
migrated to real queued **Jobs** (which also get retry semantics + show up in tooling).
- ✅ tiny, fire-and-forget, must-not-survive side effects → `defer(fn () => ...)`
- ✅ heavy, slow, or must-survive-a-crash work → dispatch a **Job**
- Capture connection state into locals **before** deferring (`$socketId = $this->connection->socketId;`) —
`$this->connection` may be gone inside the deferred callback.
---
## 8. Routing, caching & iteration
- The controller resolver **caches** the event→class map for the server's lifetime.
- After **adding/renaming** a controller or method, the running server won't see it until you clear
the cache or restart:
```bash
php artisan websocket:steer cache:clear # clear OPcache + resolver cache, no restart
php artisan websockets:restart # graceful restart
```
In dev, `config('websockets.hot_reload')` (defaults to `APP_DEBUG`) bypasses the cache automatically.
- **A consumer app must restart the WS daemon after a code/model/config swap** — the daemon caches
code at boot.
- **Introspection** (dev only): send a single-segment event (`websocket`, or a bare prefix like
`simulator`) to get a JSON description of available controllers/methods. Gated behind
`config('websockets.introspection')` or `local` env — **never enable in production**.
---
## 9. Footgun checklist (skim before writing a handler)
- [ ] `request()->user()` is `null` — use `auth()->user()`.
- [ ] Blank strings aren't nulled — add `nullable` + nullify yourself.
- [ ] Native controller default is `need_auth = true` — set `false` for public endpoints.
- [ ] Don't `return $payload` **and** `$this->success($payload)` (double `:response`).
- [ ] `$this->error(...)` doesn't halt — `return` it if it's a guard.
- [ ] There is **no** `$this->respond()` and **no** global `ws_progress()`. The verbs are
`progress()` / `success()` / `error()` / `broadcast()` / `whisper()`, all instance methods.
- [ ] `ws_whisper(event, data, …)` vs `$this->whisper(data, event, …)` — argument order differs.
- [ ] `wsSession()` takes no args.
- [ ] New/renamed controller → `websocket:steer cache:clear` or restart.
- [ ] Heavy/durable work → a Job, not `defer()`.
- [ ] Don't override the `final __construct` — use `boot()/booted()/unboot()`.
---
## 10. Source map (read these, not your memory)
| Concern | File |
|---------|------|
| Response verbs, suffixes, resolution, auth gate, introspection | `src/Websocket/Controller.php` |
| Fork/IPC model, parent relay, protocol acks, ping fast-path | `src/Websocket/Handler.php` |
| `#[Websocket]` attribute semantics | `src/Attributes/Websocket.php` |
| Event→controller mapping | `src/Websocket/ControllerResolver.php`, `src/Websocket/EventRegistry.php` |
| Global helpers (`ws_broadcast`, `ws_whisper`, `wsSession`, …) | `src/helpers_global.php` |
| Out-of-band broadcast/whisper + tracking | `src/Services/WebsocketService.php`, `src/Broadcast/BroadcastClient.php` |
| Per-connection store | `src/Websocket/ConnectionSession.php` |
| Canonical handler example | `src/Websocket/Controllers/ExampleController.php` |
| Config (introspection, hot_reload, max_concurrent_children, shutdown, broadcast_socket) | `config/websockets.php` |
| Child lifecycle: shutdown kill, listener close, fatal-error reply | `src/Server/ChildProcesses.php`, `src/Server/ChildFatalErrorGuard.php`, `src/Websocket/ChildMessageOutcome.php` |
**Frontend counterpart:** the client side of this protocol (how `:progress`/`:response`/`:error`
are consumed) is documented in `@blax-software/networking`'s `AGENTS.md`.
Audience: AI coding agents (Claude Code, Copilot, Cursor) and human contributors. This is the canonical, source-verified reference for how to use this package. The README is a marketing overview; this file is the contract. When the two disagree, this file wins (and fix the README).
Every claim below is grounded in
src/. File:line citations are given so you can verify before you trust. Do not answer questions about this package from training-data memory — read the cited source.
Every inbound WebSocket message is handled in a pcntl_fork() child process that
runs your handler synchronously and then exit(0)s. (src/Websocket/Handler.php,
forkWithSocketPair.)
Consequences — internalize these, they change how you write handlers:
sleep(), usleep(), synchronous
Http::get(), heavy DB queries — they block only that one child, never the
event loop or other clients. You do not need ReactPHP promises in a handler.
Write ordinary blocking Laravel code.defer() works — deferred callbacks are flushed just before the child
persists its session and exits. But see §7: over the WS bridge defer() is a
known hazard; prefer a real queued Job for anything heavy or that must survive.wsSession() (§6) or the
cache. Each child gets a fresh DB connection and purged Redis/cache singletons.mt_rand (ChildProcesses::reseedRandom()), so rand(),
array_rand() and shuffle() differ per message. Without it every child continues
the parent's generator state and draws the same numbers.config('websockets.max_concurrent_children') (protects
MySQL max_connections); excess messages queue in memory and run as children free up.websockets.shutdown.child_grace_seconds (default 2)
and reaps them (src/Server/ChildProcesses.php). A handler that blocks for minutes (an
engine loop) is killed on every restart, so keep its state in Redis/DB and let a later
message take over. Opt out with websockets.shutdown.terminate_children = false.<event>:error with meta.fatal = true from a shutdown function
(src/Server/ChildFatalErrorGuard.php); a child that dies with no reply at all (killed by a
signal, or the shutdown function never ran) gets <event>:error from the parent
(src/Websocket/ChildMessageOutcome.php).There are two controller families. They do not mix. Choose before you write code.
#[Websocket] attribute on a regular HTTP controller — src/Attributes/Websocket.phpTurn any App\Http\Controllers\… method into a WS endpoint with one annotation. The
same method serves HTTP and WS. Use this for thin "return data" endpoints that you
also want reachable over REST.
use BlaxSoftware\LaravelWebSockets\Attributes\Websocket;
class AerodromeController extends Controller // a normal HTTP controller
{
#[Websocket] // event: "api-v1-aerodrome.index"
public function index() { return AerodromeService::v1Index(); }
#[Websocket] // args bound BY NAME from payload
public function show(string $icao) { return AerodromeService::v1Show($icao); }
#[Websocket(event: 'user.stats', needAuth: true)] // explicit name + auth gate
public function stats() { return [...]; }
}
kebab(class path under App\Http\Controllers) + '.' + method,
e.g. Api\V1\AerodromeController::index → api-v1-aerodrome.index. Override with
event:, prefix:, or suffix:. Class-level suffix: is ignored.needAuth defaults to false here. (Constructor: Websocket.php:36.)Controller::resolveAttributeMethodArgs).
show(string $icao) receives $data['icao'].$this->progress()/success()/error()/broadcast()/whisper() —
those live on the native base class (family B). An attribute method just returns data,
which is auto-wrapped as …:response.dispatchHttpAttributeTarget). No
auth/throttle/ConvertEmptyStringsToNull/TrimStrings. See §4.Websocket\Controller — src/Websocket/Controller.phpThe richer family. Lives in app/Websocket/Controllers/<Prefix>Controller.php. Gives you the
full response API ($this->progress/success/error/broadcast/whisper) and $this->connection.
namespace App\Websocket\Controllers;
class DashboardController extends \BlaxSoftware\LaravelWebSockets\Websocket\Controller
{
public $need_auth = true; // see §3 — DEFAULT IS TRUE when omitted
public function index() // reachable at 'dashboard.index'
{
$user = auth()->user(); // §4: NOT request()->user()
$locale = request('locale') ?: app()->getLocale();
$result = DashboardService::load($user, $locale, function ($section, $data) {
$this->progress(['section' => $section, 'data' => $data]); // stream
});
return $this->success($result); // final ':response'
}
}
<class-prefix>.<method>. SimulatorController::transmit → simulator.transmit.
Resolution: kebab→Pascal + Controller, e.g. admin-user.x → AdminUserController, with
folder fallbacks (Admin\UserController). (src/Websocket/ControllerResolver.php.)#[Websocket] to these — they are resolved by class name, not the attribute registry.__construct — it's final. Use the boot() / booted() / unboot()
lifecycle hooks instead (Controller.php:35-52). Returning exactly false from boot()/booted()
aborts.Resolution order: the native resolver (family B,
app/Websocket/Controllers) is tried first; the attribute registry (family A) is the fallback when no native controller matches. So a native controller prefix shadows an attribute endpoint with the same prefix — if you expose attribute endpoints under a prefix that also has a native controller, use a distinct prefix.
:suffix wire protocolWhen a client calls an event, the server replies on suffixed event names. The client library
(@blax-software/networking) correlates the reply to the call. You produce these via:
| You write… | Client receives event… | Notes |
|---|---|---|
return $payload; (non-bool) |
<event>:response |
Auto-wrapped. The normal happy path. |
$this->success($payload) |
<event>:response |
Explicit. Returns true (suppresses auto-wrap). |
$this->progress($payload) |
<event>:progress |
Stream 0..N times before the final response. |
$this->error($msg) |
<event>:error |
String → ['message' => $msg]. Returns true. |
return true; / return false; |
(nothing) | Sends no frame — use after you already replied. |
(uncaught throw) |
<event>:error |
Auto-converted; reported to Sentry (not for ValidationException). |
Signatures (verbatim, src/Websocket/Controller.php:344-434):
final public function progress(mixed $payload = null, ?string $event = null, ?string $channel = null): bool; // :350
final public function success (mixed $payload = null, ?string $event = null, ?string $channel = null): bool; // :377
final public function error (array|string|null $payload = null, ?string $event = null, ?string $channel = null): bool; // :410
The suffix is appended automatically — never write …:response yourself. $event overrides
the base event name; $channel defaults to the current channel.
Canonical streaming pattern — hand $this->progress down into a service as a closure so the
service stays WS-agnostic:
public function transmit()
{
$result = SimulatorConversationService::doSimulation(
fn ($p) => $this->progress($p), // each chunk → 'simulator.transmit:progress'
);
return $result; // final → 'simulator.transmit:response'
}
⚠️ Footguns:
return $payload AND $this->success($payload) — the client gets two
:response frames. Pick one.$this->error(...) returns true but does not halt the method — return $this->error(...)
if you mean it as a guard clause. (Several call sites forget the return and keep executing.)progress()/success() deref $this->channel->getName() for the default channel — a controller
with no channel context will fault if you omit $channel.| Family | How to gate | Default |
|---|---|---|
A — #[Websocket] |
needAuth: attribute arg |
false |
B — native Controller |
public $need_auth = true|false; property |
true (when property is absent — Controller.php:108 reads ?? true) |
So a native controller with no $need_auth property requires auth on every method. Set
public $need_auth = false; for guest/public endpoints. The gate is controller-wide for native
controllers (no per-method granularity — use family A for per-method auth). Auth self-heal: a client
may pass data.authtoken and the package resolves it via config('websockets.auth_resolver') /
Sanctum.
request()->user()isnullover the WS bridge. WS handlers run outside HTTP middleware, so nouserResolveris installed. Use the guard, which the package populates from the socket.
$user = auth()->user(); // ✅ canonical
$user = auth()->guard()->user(); // ✅ equivalent (this is what User::auth() does)
$user = request()->user(); // ❌ null over WS — even for an authenticated connection
request() itself is rebuilt from the message payload, so request('key') and
request()->validate([...]) work exactly like HTTP — only ->user() is the trap.
Corollary — blank strings are NOT nulled. ConvertEmptyStringsToNull and TrimStrings
middleware do not run over the bridge, so an empty form field arrives as '', not null.
Add explicit nullable validation rules and nullify blank inputs yourself, or validation that
expects null will reject ''.
There is no $this in a job/command, so the $this->broadcast/whisper methods are unavailable.
Use the global helpers (src/helpers_global.php, autoloaded via composer.json autoload.files)
or WebsocketService. These send the event name verbatim — no :suffix (they are server-initiated
fan-out, not request/response).
// Always guard — these no-op (return false) if the server isn't running:
if (! ws_available()) return;
ws_broadcast('chat.message', ['text' => 'Hi'], 'chat'); // → whole channel
ws_whisper ('info:notification', $data, $socketIds, 'websocket'); // → specific sockets
ws_broadcast_except('chat.message', ['text' => 'Hi'], [$senderSocketId], 'chat');
Signatures (verbatim, src/helpers_global.php):
ws_broadcast(string $event, array $data, string $channel = 'websocket'): bool // :28
ws_whisper(string $event, array $data, array $sockets, string $channel = 'websocket'): bool // :48
ws_broadcast_except(string $event, array $data, array $excludeSockets, string $channel = 'websocket'): bool // :68
ws_client(): BroadcastClient // :86
ws_available(): bool // :98
Whispering takes socket IDs, not user IDs. Resolve them first:
$sockets = \BlaxSoftware\LaravelWebSockets\Services\WebsocketService::getUserSocketIds($user->id);
if ($sockets) ws_whisper('info:notification', $data, $sockets);
WebsocketService is the class-based equivalent (src/Services/WebsocketService.php) and also exposes
live connection state — getUserSocketIds(), isUserConnected(), getAuthedUsers(),
getActiveChannels(), getChannelConnections().
⚠️
broadcast/whisperargument order differs between the controller methods and the helpers — a real footgun:
- controller:
$this->whisper($data, $event, $sockets, $channel)— data first- helper:
ws_whisper($event, $data, $sockets, $channel)— event first
wsSession()A Redis-backed key/value store scoped to one socket, surviving across messages on that socket
(each child loads → mutates → save()s before exit). Only available inside a WS child handler.
wsSession()->increment('transmit_count');
$count = wsSession()->get('transmit_count', 0);
wsSession()->put('last_action', 'transmitted');
if (wsSession()->has('pending')) wsSession()->forget('pending');
function wsSession(): ?\BlaxSoftware\LaravelWebSockets\Websocket\ConnectionSession // helpers_global.php:131
⚠️
wsSession()takes NO arguments. Older docs showedwsSession('channel', [...])returning an "auth payload" — that signature is fictional; it never existed.wsSession()returns aConnectionSessionstore (ornulloutside a WS child — null-guard it).
defer() vs Jobsdefer() runs work after the response frame is sent (keeps handlers snappy). It works over the
bridge, but it is a documented hazard: deferred callbacks can accumulate and be re-executed by
later forked children, and they are never invoked on connection close. Heavy/durable work is being
migrated to real queued Jobs (which also get retry semantics + show up in tooling).
defer(fn () => ...)$socketId = $this->connection->socketId;) —
$this->connection may be gone inside the deferred callback.php artisan websocket:steer cache:clear # clear OPcache + resolver cache, no restart
php artisan websockets:restart # graceful restart
In dev, config('websockets.hot_reload') (defaults to APP_DEBUG) bypasses the cache automatically.websocket, or a bare prefix like
simulator) to get a JSON description of available controllers/methods. Gated behind
config('websockets.introspection') or local env — never enable in production.request()->user() is null — use auth()->user().nullable + nullify yourself.need_auth = true — set false for public endpoints.return $payload and $this->success($payload) (double :response).$this->error(...) doesn't halt — return it if it's a guard.$this->respond() and no global ws_progress(). The verbs are
progress() / success() / error() / broadcast() / whisper(), all instance methods.ws_whisper(event, data, …) vs $this->whisper(data, event, …) — argument order differs.wsSession() takes no args.websocket:steer cache:clear or restart.defer().final __construct — use boot()/booted()/unboot().| Concern | File |
|---|---|
| Response verbs, suffixes, resolution, auth gate, introspection | src/Websocket/Controller.php |
| Fork/IPC model, parent relay, protocol acks, ping fast-path | src/Websocket/Handler.php |
#[Websocket] attribute semantics |
src/Attributes/Websocket.php |
| Event→controller mapping | src/Websocket/ControllerResolver.php, src/Websocket/EventRegistry.php |
Global helpers (ws_broadcast, ws_whisper, wsSession, …) |
src/helpers_global.php |
| Out-of-band broadcast/whisper + tracking | src/Services/WebsocketService.php, src/Broadcast/BroadcastClient.php |
| Per-connection store | src/Websocket/ConnectionSession.php |
| Canonical handler example | src/Websocket/Controllers/ExampleController.php |
| Config (introspection, hot_reload, max_concurrent_children, shutdown, broadcast_socket) | config/websockets.php |
| Child lifecycle: shutdown kill, listener close, fatal-error reply | src/Server/ChildProcesses.php, src/Server/ChildFatalErrorGuard.php, src/Websocket/ChildMessageOutcome.php |
Frontend counterpart: the client side of this protocol (how :progress/:response/:error
are consumed) is documented in @blax-software/networking's AGENTS.md.
How can I help you explore Laravel packages today?