pusher/pusher-push-notifications
Official PHP server SDK for Pusher Beams. Send push notifications to iOS, Android, and web clients through a simple, fluent API with support for device interests, authentication, and batch publishing. Works standalone or alongside Laravel applications.
This is the Pusher Beams server SDK (pusher/pusher-push-notifications, namespace Pusher\PushNotifications\PushNotifications). It is not a Laravel package: no service provider, no auto-discovery, no facade, no config file. You wire it up yourself in ~10 lines.
1. Install
composer require pusher/pusher-push-notifications
2. Credentials (Beams dashboard → your instance → Keys). Never commit the secret key.
BEAMS_INSTANCE_ID=your-instance-id
BEAMS_SECRET_KEY=your-secret-key
// config/services.php
'beams' => [
'instance_id' => env('BEAMS_INSTANCE_ID'),
'secret_key' => env('BEAMS_SECRET_KEY'),
],
3. Register a singleton — the client is stateless and safe to reuse:
// app/Providers/AppServiceProvider.php
use Pusher\PushNotifications\PushNotifications;
$this->app->singleton(PushNotifications::class, fn () => new PushNotifications([
'instanceId' => config('services.beams.instance_id'),
'secretKey' => config('services.beams.secret_key'),
]));
4. Authenticate users — mobile/web clients call your endpoint to exchange a logged-in session for a Beams token:
Route::get('/beams-auth', function (Request $request, PushNotifications $beams) {
return response()->json([
'token' => $beams->generateToken((string) $request->user()->id),
]);
})->middleware('auth');
5. First use case — notify one user about something that just happened:
$beams = app(PushNotifications::class);
$beams->publishToUsers([(string) $user->id], [
'apns' => ['aps' => ['alert' => ['title' => 'Order shipped', 'body' => '...'], 'sound' => 'default']],
'fcm' => ['notification' => ['title' => 'Order shipped', 'body' => '...']],
'web' => ['notification' => ['title' => 'Order shipped', 'body' => '...']],
]);
Where to look first: the repo README (it's basically the whole API surface — ~6 methods), then the Beams docs' "Publish" page for payload shapes, and the dashboard's Debug tab, which lets you fire a test push at a specific device before you write a line of server code. Configure APNs key / FCM service account / VAPID keys in the dashboard before debugging client-side.
Wrap it in a Notification channel. This is the highest-leverage integration: you get Laravel's Notification::send(), via(), queueing, and on-demand targeting for free.
// app/Channels/BeamsChannel.php
class BeamsChannel
{
public function __construct(private PushNotifications $beams) {}
public function send($notifiable, Notification $notification): void
{
if (! $id = $notifiable->routeNotificationFor('beams', $notification)) {
return;
}
$message = $notification->toBeams($notifiable);
$this->beams->publishToUsers([(string) $id], $message->toArray());
}
}
class OrderShipped extends Notification implements ShouldQueue
{
public function via($notifiable) { return [BeamsChannel::class]; }
public function toBeams($notifiable): object
{
return new BeamsMessage(
title: 'Order shipped',
body: "Order #{$this->order->id} is on its way",
data: ['order_id' => $this->order->id],
);
}
}
Model concern for the routing key. Keep the Beams user ID decoupled from the primary key in case you ever need to rotate it (and never use emails — they change and are PII):
trait HasBeamsId
{
public function routeNotificationForBeams(): ?string
{
return $this->beams_user_id; // or (string) $this->id
}
}
Always send from a queued job, never inline. Publish is a blocking HTTP call to Pusher; putting it in a request cycle adds latency and creates a failure mode you can't retry.
final class SendBeamsPush implements ShouldQueue
{
use Queueable, InteractsWithQueue;
public int $tries = 3;
public array $backoff = [5, 30, 120];
public function __construct(public array $userIds, public array $payload) {}
public function handle(PushNotifications $beams): void
{
foreach (array_chunk($this->userIds, 500) as $chunk) {
$beams->publishToUsers($chunk, $this->payload);
}
}
}
Interests for fan-out, users for 1:1. Interests are Beams' topic primitive — the client subscribes (beamsClient.subscribe('premier-league')), the server publishes to the interest name. Use them for anything audience-shaped (a live match, a tenant-wide announcement) so you never enumerate users server-side:
$beams->publishToInterests(["tenant-{$tenant->id}"], $payload);
Namespace your interests. Since interest names are a flat global string space inside the instance, prefix them (tenant-42, team-7-news) or you will collide across features.
Handle logout deliberately. deleteUser($id) removes the user and the user↔device associations. On a shared/kiosk device that's correct; on a multi-device account it silently kills push for the user's other devices until each re-authenticates. For normal mobile logout, do nothing on the server — the client SDK's clearAllState() / stop() is enough. Reserve deleteUser for account deletion and "stop sending me anything" preferences.
Long-lived sessions need token refresh. Tokens expire (24h by default). Either let the client SDK re-fetch /beams-auth on a 401, or pass an explicit expiry with generateTokenWithExpiry((string) $user->id, 3600) and refresh on app foreground.
Make it testable by owning the boundary. Because the SDK does its own cURL calls, Http::fake() will not intercept anything. Bind against your own tiny interface and fake that:
interface PushSender {
public function toUsers(array $ids, array $payload): string; // returns publishId
}
// tests
$this->mock(PushSender::class, fn ($m) => $m->shouldReceive('toUsers')->once());
Data-only (silent) pushes for background sync: omit fcm.notification and send only fcm.data; for APNs set 'content-available' => 1 and no alert. They're best-effort and won't render — don't use them for anything user-visible.
publishToUsers(['nobody-registered'], ...) returns a publishId happily. Pushing to a user who never called setUserId, or to an interest nobody subscribed to, is a silent no-op — not an exception. Never treat a 2xx as proof of delivery; the only artifact you get back is publishId, which is useful solely for grepping against the Beams dashboard.publishToUsers([1], ...) vs [(string) 1] vs "u-1" are three different users. Cast explicitly, everywhere, and never change the format after launch — existing device registrations are keyed on it.a-zA-Z0-9_=-@,.;: and capped in length (~164 chars), max 10 interests per device. A stray space or # in "premier league" gives you a 400, and a silent failure to subscribe. Slugify before subscribing.data overflow fast if you stuff serialized models in there. Send IDs, not objects.fcm.notification.android_channel_id (or channel_id) to a channel your app actually created; otherwise the push is dropped with no server-side error.->delay() on the job. Guard against double-sends from retries (retry after a timeout re-publishes) with ShouldBeUnique or a "sent" flag — Beams has no dedupe key.instanceId/secretKey pair must be from the same instance; the most common cause is a .env mix-up between staging and production instances, or a key regenerated in the dashboard without redeploying.Http::fake(), no Guzzle middleware, and no retry magic. You build retry/backoff yourself (see $tries/$backoff above).auth()->user() inside a closure bound at container build time.config:cache will happily freeze a null instance ID if the env var is missing at build time. Add a boot-time guard that throws on empty credentials in non-local environments — you want that to fail at deploy, not at 3am on the first push.host), which is the escape hatch for proxies or local stubs — worth checking the constructor signature before you reach for a fork.How can I help you explore Laravel packages today?