Extensions

WebSocket

Embedded Reverb/Pusher-compatible WebSocket runtime and Laravel broadcaster.

Overview

Pogo WebSocket embeds a Reverb/Pusher-compatible WebSocket runtime into FrankenPHP.

It provides:

  • a Caddy HTTP handler for WebSocket connections,
  • PHP native publish functions,
  • a Laravel broadcasting driver,
  • standard Pusher private and presence channel signatures from Laravel's broadcasting auth endpoint,
  • optional FrankenPHP auth worker fallback when clients do not send standard signatures,
  • signed Reverb/Pusher-compatible HTTP publish and management endpoints,
  • optional Redis Pub/Sub for multi-node fanout.

Status and fit

Pogo WebSocket is experimental. It is suitable for demos, local testing, and controlled evaluation of a FrankenPHP-native realtime runtime.

Use it when:

  • You want to evaluate Pusher-style broadcasting without a separate Node.js or hosted realtime service.
  • Your client can use Laravel Echo or the Pusher protocol subset.
  • At-most-once realtime delivery is acceptable.

Avoid presenting it as a drop-in production replacement for Laravel Reverb, Pusher, or hosted realtime systems until you validate behavior, benchmarks, and failure modes for your topology.

Supported protocol behavior includes connection establishment, ping/pong, public/private/presence subscriptions, client events on private and presence channels, pusher:signin, signed HTTP event and batch publishing, local channel/user management endpoints, and user connection termination.

Build

Compile the module into FrankenPHP:

Terminal
xcaddy build \
  --with github.com/dunglas/frankenphp@v1.12.4 \
  --with github.com/dunglas/frankenphp/caddy@v1.12.4 \
  --with github.com/y-l-g/websocket/module@main

Install the Laravel driver:

Terminal
composer require pogo/websocket
php artisan pogo:ws-install

Caddy configuration

Caddyfile
{
  frankenphp {
    worker {
      file public/frankenphp-worker.php
    }
  }

  order pogo_websocket before php_server
}

:8080 {
  @websocket path /app/* /apps/* /up /pogo/health
  route @websocket {
    pogo_websocket {
      app_id {$REVERB_APP_ID}
      app_key {$REVERB_APP_KEY}
      app_secret {$REVERB_APP_SECRET}
      # auth_script public/websocket-worker.php
      # auth_path /broadcasting/auth
      webhook_secret {$POGO_WEBHOOK_SECRET}
      allowed_origins https://app.example.com https://admin.example.com

      handshake_rate 100
      handshake_burst 50
      max_connections 10000
      max_auth_body 16384
      max_concurrent_auth 100
      broker_queue_size 1024
      shard_queue_size 1024

      num_workers 2
      num_shards 8

      ping_period 54s
      pong_wait 60s
      write_wait 10s
      shutdown_timeout 10s

      # redis_host redis:6379
      # redis_password {$REDIS_PASSWORD}
      # redis_db 0
      # redis_tls false
    }
  }

  route {
    root * public
    encode zstd br gzip

    php_server {
      index frankenphp-worker.php
      try_files {path} frankenphp-worker.php
      resolve_root_symlink
    }
  }
}

By default, WebSocket upgrades accept all origins, matching Reverb's default allowed_origins ['*']. Configure allowed_origins to restrict browser clients. Entries may be *, exact http:// or https:// origins, or host-only values such as app.example.com.

If auth_script is configured and a private or presence subscription omits the standard Pusher auth signature, the module calls the FrankenPHP auth worker and validates the returned signature before joining the channel.

Application integration

Set Laravel environment variables:

.env
BROADCAST_CONNECTION=pogo
REVERB_APP_ID=pogo-app
REVERB_APP_KEY=change-me-to-a-random-public-app-key
REVERB_APP_SECRET=change-me-to-a-long-random-secret
POGO_WEBHOOK_SECRET=change-me-to-a-different-random-secret

VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST=localhost
VITE_REVERB_PORT=8080
VITE_REVERB_SCHEME=http

Configure the broadcasting connection:

config/broadcasting.php
'pogo' => [
    'driver' => 'pogo',
    'key' => env('REVERB_APP_KEY'),
    'secret' => env('REVERB_APP_SECRET'),
    'app_id' => env('REVERB_APP_ID'),
],

Use Laravel Echo with the Pusher client:

resources/js/echo.js
import Echo from 'laravel-echo'
import Pusher from 'pusher-js'

window.Pusher = Pusher

window.Echo = new Echo({
  broadcaster: 'reverb',
  key: import.meta.env.VITE_REVERB_APP_KEY,
  wsHost: import.meta.env.VITE_REVERB_HOST || window.location.hostname,
  wsPort: import.meta.env.VITE_REVERB_PORT || 80,
  wssPort: import.meta.env.VITE_REVERB_PORT || 443,
  forceTLS: (import.meta.env.VITE_REVERB_SCHEME || 'https') === 'https',
  disableStats: true,
  enabledTransports: ['ws', 'wss'],
})

PHP API

pogo_websocket_publish(string $appId, string $channel, string $event, string $data): int;
pogo_websocket_broadcast_multi(string $appId, string $channels, string $event, string $data): int;

Return status codes:

CodeMeaning
0Success
1Hub missing
2Channel too long
3Event too long
4Payload too large
5Invalid payload JSON
6Broker publish failed
7Invalid multi-channel JSON
8Broker queue full
9Shard queue full

The Laravel broadcaster converts native failures into BroadcastException.

Operations

  • Use strong, different values for REVERB_APP_SECRET and POGO_WEBHOOK_SECRET.
  • Set allowed_origins for browser clients that connect from another origin.
  • Enforce per-client connection limits at the reverse proxy if FrankenPHP is behind a proxy that hides client IPs.
  • Use Redis Pub/Sub only for best-effort multi-node fanout; messages are not persisted, replayed, or acknowledged.
  • Prometheus metrics are exposed through Caddy admin metrics at /metrics.
  • Use BROADCAST_CONNECTION=pogo for same-process native publishing. Use a manually configured reverb connection only when broadcasts must be sent from another process or container through the HTTP API.

Troubleshooting

  • 4100 over capacity: increase max_connections or reduce client count.
  • 4009 connection unauthorized: verify app_id, app_key, app_secret, and the REVERB_* values in Laravel and Caddy.
  • Too many requests: tune handshake_rate and handshake_burst.
  • Private or presence auth fails: confirm Laravel's /broadcasting/auth returns standard Pusher signatures, or configure auth_script for the FrankenPHP fallback path.