pusher/pusher-php-server
Official PHP library for the Pusher Channels HTTP API. Install via Composer, create a Pusher instance with your app credentials, and publish events or authenticate channels from PHP 7.3–8.4, including Laravel broadcasting.
Installation. In almost every Laravel app you never install this explicitly — laravel/framework already depends on pusher/pusher-php-server (^7.x) because Illuminate\Broadcasting\Broadcasters\PusherBroadcaster wraps it. Add it directly only when you need to pin the version or use the low-level client (Pusher\Pusher) for things broadcasting does not expose — channel queries, user authentication, webhooks, batch triggers:
composer require pusher/pusher-php-server:^7.3
Configuration. Look at config/broadcasting.php first (the pusher connection block) and populate .env:
BROADCAST_CONNECTION=pusher # Laravel 11+; BROADCAST_DRIVER on older versions
PUSHER_APP_ID=123456
PUSHER_APP_KEY=xxxxxxxxxxxxxxxxxxxx
PUSHER_APP_SECRET=xxxxxxxxxxxxxxxxxxxx
PUSHER_APP_CLUSTER=eu
PUSHER_SCHEME=https
PUSHER_USE_TLS=true
# Only for self-hosted / Pusher-compatible servers (soketi, laravel-websockets):
# PUSHER_HOST=127.0.0.1
# PUSHER_PORT=6001
The array passed as the fourth Pusher constructor argument maps 1:1 to these keys (cluster, host, port, scheme, useTLS, timeout, encryption_master_key_base64), which is why the config file looks the way it does.
First use case. Create a broadcast event — this is the 90% path:
class OrderShipped implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public Order $order) {}
public function broadcastOn(): array
{
return [new PrivateChannel('orders.'.$this->order->user_id)];
}
public function broadcastAs(): string { return 'OrderShipped'; }
public function broadcastWith(): array { return ['id' => $this->order->id, 'status' => 'shipped']; }
}
Authorize it in routes/channels.php (Broadcast::channel('orders.{userId}', fn ($user, $userId) => (int) $user->id === (int) $userId);), then listen in Echo. To verify credentials before touching the front end, run a one-liner in tinker:
app(Pusher\Pusher::class)->trigger('test-channel', 'hello', ['ok' => true]);
If that returns without an exception, the API side is wired correctly and any remaining problem is on the client/authorization side.
To get the underlying client from Laravel's broadcaster (useful when you don't want a second instance with duplicated config):
$pusher = app(Illuminate\Broadcasting\BroadcastManager::class)->connection('pusher')->getPusher();
Broadcast through events, reach for the raw client only when needed. ShouldBroadcast puts the broadcast on the queue (safe, retryable); ShouldBroadcastNow sends inline from the request/worker. Use the latter only for low-latency, non-critical payloads — a slow Pusher API call inside a web request is a request-latency tax.
Fan-out with a single API call. trigger() accepts an array of channels and performs one HTTP request. triggerBatch() sends up to 10 distinct events in one call — ideal for notifying 10 channels of the same payload without 10 round trips.
Exclude the acting user. Pass ['socket_id' => $request->header('X-Socket-Id')] (or your own socket-id plumbing) so the originator doesn't receive an echo of their own action — the classic "my own message appears twice in chat" fix.
Async triggering in queue workers and Octane. triggerAsync() / triggerBatchAsync() return Guzzle promises; useful when a job must publish to several channel groups and you want them in flight concurrently. Always ->wait() before the job returns so failures surface.
Custom Guzzle client for observability and retries. Bind a configured GuzzleHttp\Client (with middleware for tracing, RetryMiddleware, or a MockHandler in tests) as a singleton and pass it as the fifth constructor argument. This is the cleanest seam for testing code that calls $pusher->trigger() directly.
Webhooks. Laravel's broadcasting routes don't cover Pusher webhooks — write a small controller in the api middleware group (CSRF-exempt), verify with $pusher->webhook($headers, $rawBody) in a try/catch for Pusher\PusherException, then queue the actual processing and return 200 immediately. Handle channel_occupied, channel_vacated, member_added, member_removed and client_event.
Server-side presence queries. For "who's online" dashboards or support widgets, getChannels(['filter_by_prefix' => 'presence-']) and getChannelInfo($name, ['info' => 'user_count,subscription_count']) are cheaper and simpler than maintaining your own presence table — but note subscription_count must be enabled in the Pusher dashboard first.
End-to-end encryption. Set encryption_master_key_base64 in the config and use private-encrypted- channel names. Only apply it to channels that genuinely need it; the ciphertext is unreadable in the Pusher debug console, which complicates debugging.
Testing. For broadcast events, Event::fake() / Broadcast::fake() is enough — you're asserting the intent, not the HTTP call. For direct $pusher->trigger() code, bind a Pusher\Pusher built on a Guzzle client with a MockHandler, or wrap your publishing in a thin RealtimePublisher service and fake that instead. Don't hit the real API in CI.
PUSHER_APP_ID / PUSHER_APP_SECRET empty or mismatched is the single most common failure: you'll get 401 / ApiErrorException: Invalid signature. If PUSHER_HOST is set but PUSHER_PORT/PUSHER_SCHEME are stale in .env, you'll get connection timeouts or 404s instead.host, port, and almost always useTLS=false plus scheme=http. A leftover PUSHER_USE_TLS=true produces cryptic cURL/SSL errors rather than a clear message.BROADCAST_CONNECTION. BROADCAST_DRIVER=pusher silently does nothing on 11+, leaving broadcasting on null and no events sent.broadcastWith(). With the raw client, only pass true as the sixth trigger() argument when your $data is already a JSON string — otherwise clients receive a JSON-encoded string instead of an object.public bool $afterCommit = true; on the event (or DB::afterCommit(...)) — this is the most common "works locally, races in production" bug with Pusher.trigger() call, because encryption is per-request. This throws; split into two calls.$request->headers->all() returns lowercased keys (x-pusher-signature), while the library looks up X-Pusher-Signature. Build the array explicitly — ['X-Pusher-Key' => $request->header('X-Pusher-Key'), 'X-Pusher-Signature' => $request->header('X-Pusher-Signature')] — and always pass $request->getContent() (raw body), never validated/parsed data, or signature verification fails.Pusher\ApiErrorException (or Pusher\PusherException for client-side argument problems). PusherBroadcaster re-wraps these as BroadcastException. Catch at the job level and let the queue retry — but make handlers idempotent, since a retried broadcast can duplicate a message.socket_id exclusion only works if the client actually sends its socket id to your backend; it's not inferred. With Echo, read it from Echo.socketId() and ship it as a header.timeout (seconds) in the options array. The default can block a queue worker far longer than you'd like during a Pusher outage.getChannelInfo() results for a few seconds if you render them in a live dashboard.Pusher implements LoggerAwareInterface — call setLogger(app('log')), or set 'log' => true on the broadcasting connection so Laravel attaches its logger automatically. This is the fastest way to see the exact request URL and response body when auth fails.broadcastAs() and listen for that name in JS; renaming the PHP class otherwise silently breaks client listeners, and there's no server-side error to warn you.How can I help you explore Laravel packages today?