Socket Conveyor is a PHP 8.2+ WebSocket message router built on OpenSwoole. It can run in two modes:
- Native Conveyor protocol, where clients send Conveyor action messages.
- Pusher/Reverb-compatible protocol, where Laravel, Laravel Echo, and
pusher-jsuse the normal Pusher wire format and REST broadcast API.
Install the package with Composer:
composer require kanata-php/socket-conveyorFor local development in this repository, install dependencies from the project root:
composer install- PHP 8.2 or newer.
ext-openswooleversion 22.0 or newer.- Composer.
- A host and port that the WebSocket server can bind to.
The default Conveyor server host is 0.0.0.0 and the default port is 8989.
Create a small PHP entrypoint, for example server.php:
<?php
use Conveyor\Constants;
use Conveyor\ConveyorServer;
require __DIR__ . '/vendor/autoload.php';
(new ConveyorServer())
->host('127.0.0.1')
->port(8989)
->serverOptions([
'worker_num' => 1,
'task_worker_num' => 1,
])
->conveyorOptions([
Constants::WEBSOCKET_SUBPROTOCOL => Constants::SOCKET_CONVEYOR,
Constants::USE_PRESENCE => false,
Constants::USE_ACKNOWLEDGMENT => false,
])
->start();Run it:
php server.phpConveyorServer::start() initializes default persistence tables, creates an
OpenSwoole WebSocket server, selects the configured subprotocol, and starts the
server.
Native mode is selected by default with:
Constants::WEBSOCKET_SUBPROTOCOL => Constants::SOCKET_CONVEYORThe subprotocol value is socketconveyor.com.
On a successful native WebSocket handshake, Conveyor sends a connection info message:
{
"action": "connection-info",
"data": "{\"fd\":1,\"event\":\"connection-info\"}"
}If acknowledgment is enabled, this connection info message also includes an
id.
Clients send JSON action messages. Every action message must include action.
Most actions also require action-specific fields.
base-action echoes a message back to the current socket:
{
"action": "base-action",
"data": "hello"
}Response:
{
"action": "base-action",
"data": "hello",
"fd": 1
}fanout-action sends data to every established socket:
{
"action": "fanout-action",
"data": "hello everyone"
}If Constants::WEBSOCKET_SERVER_TOKEN is configured, fanout-action also
requires auth with that server token.
channel-connect connects the current socket fd to a single channel:
{
"action": "channel-connect",
"channel": "orders.1"
}If Constants::WEBSOCKET_SERVER_TOKEN is configured, include either the server
token or a channel-scoped temporary auth token:
{
"action": "channel-connect",
"channel": "orders.1",
"auth": "your-token"
}broadcast-action sends data to the current channel. If the sender is
connected to a channel, Conveyor broadcasts to other sockets in that same
channel. If the sender is not connected to a channel, Conveyor broadcasts to
sockets that are not in channels.
{
"action": "broadcast-action",
"data": "order updated"
}channel-disconnect removes the current socket fd from its channel:
{
"action": "channel-disconnect"
}acknowledge-action is used by clients to acknowledge receipt of a server
message when acknowledgment is enabled:
{
"action": "acknowledge-action",
"data": "message-id"
}Add custom action handlers through Constants::ACTIONS. Each action must
implement ActionInterface.
use Conveyor\Constants;
use Conveyor\ConveyorServer;
use Tests\Assets\SampleAction;
(new ConveyorServer())
->port(8989)
->conveyorOptions([
Constants::ACTIONS => [
new SampleAction(),
],
])
->start();An action implements:
public function execute(array $data): mixed;
public function send(string $data, ?int $fd = null, bool $toChannel = false): void;
public function getName(): string;AbstractAction validates the base action field, runs action-specific
validation, performs optional acknowledgment, then calls execute().
In native mode, a normal channel flow is:
- Client connects to the WebSocket server.
- Server sends
connection-info. - Client sends
channel-connectwith achannel. - Server records the fd-to-channel association.
- Client sends
broadcast-actionwithdata. - Server wraps outbound data as
{ "action": "broadcast-action", "data": ..., "fd": senderFd }. - Server delivers the message to other established sockets in the same channel.
Example client messages:
{
"action": "channel-connect",
"channel": "chat.room.1"
}{
"action": "broadcast-action",
"data": {
"body": "hello"
}
}Enable native presence with:
Constants::USE_PRESENCE => trueWhen a socket connects to or disconnects from a channel, Conveyor broadcasts a
presence payload to that channel. The presence event name is
channel-presence.
A presence message is sent inside the normal action envelope:
{
"action": "channel-connect",
"data": "{\"event\":\"channel-presence\",\"channel\":\"chat.room.1\",\"fds\":[1]}",
"fd": 1
}The presence payload can be customized with the
Constants::FILTER_PRESENCE_MESSAGE_CONNECT and
Constants::FILTER_PRESENCE_MESSAGE_DISCONNECT filters.
Enable native acknowledgment with:
Constants::USE_ACKNOWLEDGMENT => true,
Constants::ACKNOWLEDGMENT_ATTEMPTS => 3,
Constants::ACKNOWLEDGMENT_TIMOUT => 0.5,When acknowledgment is enabled, outgoing pushed messages receive an id.
Clients should reply with:
{
"action": "acknowledge-action",
"data": "the-message-id"
}Protect native WebSocket handshakes and protected actions with:
Constants::WEBSOCKET_SERVER_TOKEN => 'local-server-token'When this option is set, native WebSocket clients must connect with:
ws://127.0.0.1:8989?token=local-server-token
fanout-action requires auth to equal the server token. channel-connect
accepts either the server token or a temporary channel token created by the
native auth endpoint.
Native mode also handles HTTP requests on the same OpenSwoole server.
Create a temporary channel auth token:
curl -X POST "http://127.0.0.1:8989/conveyor/auth?token=local-server-token" \
-H "Content-Type: application/json" \
-d '{"channel":"orders.1"}'Response:
{
"auth": "temporary-token"
}Use the returned auth value in a later channel-connect message for the same
channel. Temporary channel tokens are consumed after use.
If a Laravel application owns user authentication but you are using Conveyor's
native protocol, have Laravel call Conveyor's /conveyor/auth endpoint from a
server-side route. Keep CONVEYOR_SERVER_TOKEN on the server only; do not expose
it through Vite or browser JavaScript.
Laravel .env:
CONVEYOR_HTTP_URL=http://127.0.0.1:8989
CONVEYOR_SERVER_TOKEN=local-server-tokenExample Laravel route:
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
Route::post('/conveyor/channel-auth', function (Request $request) {
$data = $request->validate([
'channel' => ['required', 'string'],
]);
// Authorize the current user for this channel before minting a token.
abort_unless($request->user()?->can('joinConveyorChannel', $data['channel']), 403);
$url = rtrim(config('services.conveyor.url'), '/') . '/conveyor/auth?'
. http_build_query(['token' => config('services.conveyor.server_token')]);
$response = Http::asJson()->post($url, ['channel' => $data['channel']]);
abort_unless($response->successful(), 502);
return $response->json();
})->middleware('auth');Add the service config:
// config/services.php
'conveyor' => [
'url' => env('CONVEYOR_HTTP_URL', 'http://127.0.0.1:8989'),
'server_token' => env('CONVEYOR_SERVER_TOKEN'),
],The browser asks Laravel for a channel token, then uses it for the native
WebSocket handshake and channel-connect message:
const response = await fetch('/conveyor/channel-auth', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({ channel: 'orders.1' }),
})
const { auth } = await response.json()
const socket = new WebSocket(`ws://127.0.0.1:8989/?token=${encodeURIComponent(auth)}`)
socket.addEventListener('open', () => {
socket.send(JSON.stringify({
action: 'channel-connect',
channel: 'orders.1',
auth,
}))
})This is only for Conveyor's native protocol. For Laravel Echo with the
Pusher/Reverb-compatible mode, use Laravel's normal /broadcasting/auth flow
instead.
Force a server-side broadcast to a native channel:
curl -X POST "http://127.0.0.1:8989/conveyor/message?token=local-server-token" \
-H "Content-Type: application/json" \
-d '{"channel":"orders.1","message":"order updated"}'Successful response:
{
"success": "Message sent successfully!"
}Common native endpoint errors:
401withUnauthorized!: missing or invalidtokenwhen a server token is required.400withChannels not enabled!: channel persistence is unavailable.404withChannel not found!: no socket is currently connected to the requested channel.400withInvalid message!: the request body has no non-emptymessage.
Use Pusher mode when you want Laravel's stock reverb or pusher broadcaster,
Laravel Echo, and pusher-js to talk to Conveyor.
<?php
use Conveyor\Constants;
use Conveyor\ConveyorServer;
require __DIR__ . '/vendor/autoload.php';
(new ConveyorServer())
->host('127.0.0.1')
->port(8990)
->serverOptions([
'worker_num' => 1,
'task_worker_num' => 1,
])
->conveyorOptions([
Constants::WEBSOCKET_SUBPROTOCOL => Constants::PUSHER,
Constants::USE_PRESENCE => true,
Constants::APPS => [[
'app_id' => 'local-app',
'key' => 'local-key',
'secret' => 'local-secret',
'enable_client_messages' => true,
'enabled' => true,
]],
])
->start();The Pusher subprotocol value is pusher.com.
WebSocket clients connect to:
ws://127.0.0.1:8990/app/local-key
For known and enabled apps, Conveyor sends pusher:connection_established with
a socket_id. Unknown or disabled apps receive pusher:error code 4001 and
the socket is closed.
The included real-client smoke server uses this setup:
php examples/pusher-real/run-conveyor.phpDefault local credentials:
PUSHER_APP_ID=local-app
PUSHER_APP_KEY=local-key
PUSHER_APP_SECRET=local-secret
CONVEYOR_HOST=127.0.0.1
CONVEYOR_PORT=8990
Configure Laravel to use its built-in Reverb-style values:
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=local-app
REVERB_APP_KEY=local-key
REVERB_APP_SECRET=local-secret
REVERB_HOST=127.0.0.1
REVERB_PORT=8990
REVERB_SCHEME=http
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"If your Laravel app uses the pusher broadcaster, use equivalent PUSHER_*
values with the same app id, key, secret, host, port, and scheme.
Install Echo and the Pusher client:
npm install laravel-echo pusher-jsBrowser setup:
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,
wsPort: import.meta.env.VITE_REVERB_PORT,
wssPort: import.meta.env.VITE_REVERB_PORT,
forceTLS: import.meta.env.VITE_REVERB_SCHEME === 'https',
enabledTransports: ['ws', 'wss'],
})For private and presence channels, keep the WebSocket host pointed at Conveyor but point Echo authorization at your Laravel HTTP application:
VITE_LARAVEL_URL=http://localhost:8000window.Echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT,
wssPort: import.meta.env.VITE_REVERB_PORT,
forceTLS: import.meta.env.VITE_REVERB_SCHEME === 'https',
enabledTransports: ['ws', 'wss'],
authEndpoint: `${import.meta.env.VITE_LARAVEL_URL}/broadcasting/auth`,
auth: {
withCredentials: true,
},
})Use normal Echo APIs:
window.Echo.channel('orders')
.listen('.OrderShipped', event => {
console.log(event)
})
window.Echo.private('orders.1')
.listen('.OrderUpdated', event => {
console.log(event)
})
window.Echo.join('room.1')
.here(users => console.log('here', users))
.joining(user => console.log('joining', user))
.leaving(user => console.log('leaving', user))
.listenForWhisper('typing', event => console.log('typing', event))Laravel continues to own channel authorization in routes/channels.php:
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('orders.{orderId}', function ($user, int $orderId) {
return true;
});
Broadcast::channel('room.{roomId}', function ($user, int $roomId) {
return [
'id' => $user->id,
'name' => $user->name,
];
});Broadcast normally:
broadcast(new OrderShipped($order))->toOthers();Conveyor receives Laravel's signed REST publish request and honors socket_id
exclusion for toOthers().
Public channels do not require authorization. With Echo:
window.Echo.channel('orders')This subscribes to the Pusher channel name orders.
Private channels require a valid /broadcasting/auth response from Laravel.
With Echo:
window.Echo.private('orders.1')Echo subscribes to the Pusher channel name private-orders.1.
Presence channels also require /broadcasting/auth, and the auth response must
include channel_data with a user_id and optional user_info. With Echo:
window.Echo.join('room.1')Echo subscribes to the Pusher channel name presence-room.1.
In Pusher mode, Conveyor validates private and presence channel signatures
using the configured app key and secret. An invalid signature produces
pusher:error with unauthorized code 4009.
There is no Conveyor-specific browser token in Pusher mode. The authorized connection is the normal Pusher/Reverb sequence:
Note: In Pusher mode, the browser asks Laravel whether it may enter a private or presence channel; Laravel checks the logged-in user and returns a signed permission payload that Conveyor verifies with the shared app secret. In Conveyor's native protocol, Conveyor itself uses a server token or temporary channel token instead of Laravel's normal
/broadcasting/authPusher flow.
- Echo connects to Conveyor at
/app/{key}and receives asocket_id. - Echo posts
socket_idandchannel_nameto Laravel's/broadcasting/auth. - Laravel checks
routes/channels.php. - Laravel returns an
authsignature, and presence channels also returnchannel_data. - Echo sends that
authvalue inpusher:subscribe. - Conveyor validates the signature with the app secret from
Constants::APPS.
Private auth response shape:
{
"auth": "local-key:computed-hmac-signature"
}Presence auth response shape:
{
"auth": "local-key:computed-hmac-signature",
"channel_data": "{\"user_id\":\"1\",\"user_info\":{\"name\":\"Savio\"}}"
}Laravel generates this response when its built-in reverb or pusher
broadcaster uses the same app id, key, and secret configured in Conveyor.
If the browser shows a 403, or posts to a frontend dev server such as
http://localhost:8081/broadcasting/auth, the client did not authorize and is
not subscribed. The auth request must reach Laravel because Laravel owns
/broadcasting/auth, routes/channels.php, and the authenticated user session.
Pusher/Reverb-compatible mode exposes signed REST endpoints on the same server.
Publish one event:
POST /apps/{app_id}/events
Body:
{
"name": "OrderShipped",
"channels": ["orders.1"],
"data": "{\"id\":1,\"total\":99}",
"socket_id": "123.456"
}You may send either channels or a single channel. data must be a string
and is limited to 10000 bytes. socket_id is optional; when present, Conveyor
does not deliver the event to that socket.
Publish a batch:
POST /apps/{app_id}/batch_events
Body:
{
"batch": [
{
"name": "OrderShipped",
"channel": "orders.1",
"data": "{\"id\":1}"
}
]
}Batches are limited to 10 events.
Inspect channels:
GET /apps/{app_id}/channels
GET /apps/{app_id}/channels/{channel}
GET /apps/{app_id}/channels/{presence-channel}/users
REST requests must include Pusher-style signed query parameters:
auth_keyauth_timestampauth_version=1.0body_md5for requests with a bodyauth_signature
The timestamp tolerance is 600 seconds. Bad app credentials, body hashes, or
signatures return 401. Stale timestamps return 400.
The example router signs a REST publish request with
Conveyor\SubProtocols\Pusher\PusherSigner in
examples/pusher-real/router.php.
Run the Pusher-compatible smoke server from the repository root.
Terminal 1:
php examples/pusher-real/run-conveyor.phpTerminal 2:
php -S 127.0.0.1:8991 examples/pusher-real/router.phpOpen the raw Pusher smoke page:
http://127.0.0.1:8991
Open the Laravel Echo smoke page:
http://127.0.0.1:8991/echo.html
Expected checks:
- The page reaches
connectedand shows asocket_id. - Public, private, and presence subscriptions log
subscribed. - The Public, Private, and Presence buttons produce
DemoEventlogs. Public toOthers()sends the currentsocket_id, so the current browser tab does not receive its own REST-triggered event.- Opening a second tab and using the whisper button sends a client event to the other tab.
Class OpenSwoole\WebSocket\Server not found or Composer reports a missing
extension: install and enable ext-openswoole >=22.0 for the PHP binary running
the server.
The server does not start: check that the configured port is free. Native tests
use port 8989; Pusher feature tests and the real smoke server use port
8990; the example HTTP router uses port 8991.
Native WebSocket connection closes during handshake: if
Constants::WEBSOCKET_SERVER_TOKEN is set, connect with ?token=....
Native channel-connect fails with Failed to connect to channel: the auth
field did not match the server token or a valid temporary channel token for that
channel.
Native HTTP broadcast returns Channel not found!: at least one socket must be
connected to the requested channel before /conveyor/message can send to it.
Laravel Echo private or presence channels fail with 403: Echo is sending
/broadcasting/auth to the wrong HTTP app, the Laravel session is missing, or
the channel callback in routes/channels.php denied access.
Pusher REST publish returns 401: check app_id, app key, app secret,
body_md5, and auth_signature. The request path used to compute the
signature must exactly match the path being requested.
Pusher REST publish returns 400 with Stale auth_timestamp: make sure the
client machine clock is close to the server clock.
Events are published but the sender receives its own Laravel broadcast: pass
the current socket_id with the REST payload. Laravel's toOthers() does this
through the Pusher/Reverb broadcaster.
Client whispers do not work: Pusher client events must use an event name that
starts with client-, the app must have enable_client_messages set to true,
and another socket must be subscribed to the same channel.
Run the full test suite:
composer testRun PHPUnit directly:
vendor/bin/phpunit --testsuite AllRun the Pusher WebSocket feature tests:
vendor/bin/phpunit tests/Feature/PusherWebSocketTest.phpRun the native message router unit tests:
vendor/bin/phpunit tests/Unit/MessageRouterTest.phpRun static analysis:
composer phpstan