sly/notification-pusher
Send push notifications from Laravel through a single, unified API. Notification Pusher wraps multiple providers (Firebase Cloud Messaging, APNs, OneSignal and more) behind one clean interface, so you can swap services without rewriting code and keep your notification logic simple and testable.
NotificationPusher is a standalone PHP library first, so the "day-to-day" experience differs depending on whether you wire it up manually or via the Laravel bridge (sly/notification-pusher-laravel). Start with the bridge — that's the 90% path for a Laravel app.
Minimal install:
composer require sly/notification-pusher sly/notification-pusher-laravel
php artisan vendor:publish --provider="Sly\NotificationPusher\NotificationPusherServiceProvider"
The published config/notification-pusher.php is the first file to open. It defines your adapters (APNS and GCM by default), their credentials (certificate path + passphrase for APNS, API key for GCM), and a default adapter key. If you're doing iOS you'll drop a .pem into storage/ and set its path + passphrase here; for Android you'll need a (legacy) GCM server key.
First use case — send one push to one device:
use Sly\NotificationPusher\Model\Device;
use Sly\NotificationPusher\Model\Message;
use Sly\NotificationPusher\Model\Push;
$device = new Device('abc123...'); // raw APNS/FCM token
$message = new Message('Hello from Laravel', ['badge' => 1, 'sound' => 'default']);
$push = new Push($adapter, [$device], $message); // $adapter = ApnsAdapter or GcmAdapter
$push->setAdapter($adapter)->setDevices([$device])->setMessage($message);
Then feed it to the manager:
NotificationPusher::add($push);
NotificationPusher::push(); // fire-and-forget, returns the processed Push objects
Where to look first in the source: Sly\NotificationPusher\PushManager (the orchestrator), Sly\NotificationPusher\Model\Push (per-batch state, feedback, sent/failed devices), Sly\NotificationPusher\Model\Message (text + options that map to payload keys), and the concrete adapters Adapter\ApnsAdapter / Adapter\GcmAdapter. Everything you can configure is a method on those four classes or a config key.
Per-device adapter routing. The manager handles one adapter at a time. In practice you build a Push per adapter per payload — one ApnsAdapter push, one GcmAdapter push — and register both before calling push(). That's also where you fork the payload: iOS gets badge/sound/content-available, Android gets data/notification keys. Don't try to share a Message object across platforms; clone or build two.
Batching by message. Each Push carries an array of Device objects. Batch sends in groups of ~500–1000 (APNS and FCM both have payload/size limits, and a single bad token or oversized payload should not take down the whole batch — see gotchas). A Push object is a logical "notification to N devices", which maps cleanly to a queued job.
Wrap in a service + queue. The classic wiring is a PushNotifier service that pulls device tokens from your users/devices tables, groups them by platform, builds Push objects, and dispatches through a queued job. Since the underlying library is synchronous and stream-based, running push() inside a request is a latency trap. Use NotificationPusher::push() inside handle() and record getSentDevices() / getFeedback() back on your models:
class SendPushNotification implements ShouldQueue
{
public function handle(): void
{
foreach ($this->devices->groupBy('platform') as $platform => $devices) {
$push = new Push($this->adapterFor($platform), $devices->pluck('token')->all(), $this->message);
NotificationPusher::add($push);
}
$processed = NotificationPusher::push();
foreach ($processed as $push) {
foreach ($push->getSentDevices() as $token) { /* mark sent */ }
foreach ($push->getFeedback() as $feedback) { /* prune invalid tokens */ }
}
}
}
Use the feedback loop for token hygiene. Push::getFeedback() (and the APNS feedback service via ApnsAdapter::getFeedback()) is the single most valuable part of the library operationally — it tells you which tokens are dead (DeviceNotRegistered, Unregistered). Persist this and prune tokens, otherwise your bounce rate creeps up and your batch sizes become unaffordable.
Testing pattern. Because the adapters are just objects, you can inject fakes: swap the adapter for a stub that records push() calls, or use NotificationPusher::shouldReceive('push')->once() for the facade. Prefer binding a fake adapter into the container and asserting on getSentDevices() — that exercises your real Push construction, not just that the facade was called.
Custom payloads per platform. Message takes text + options; options are adapter-specific and passed through more-or-less verbatim. Use them for deep-link URLs, badge, sound, and custom data blocks. Keep platform-specific keys distinct — APNS rejects payloads with unknown top-level keys if they're not under aps.
GCM is dead. The GcmAdapter speaks the legacy GCM HTTP endpoint, which Google shut down in favor of Firebase Cloud Messaging years ago. The library's last commit is well past that. For Android, expect to fork the adapter or route to FCM yourself — don't assume GcmAdapter works out of the box in 2024+. Treat the config gcm.apiKey block as a placeholder you'll replace.
APNS binary protocol is also dead. ApnsAdapter connects to gateway.push.apple.com:2195 with a legacy .pem over stream_socket_client(). Apple deprecated the binary provider API in 2020 and forced HTTP/2 (api.push.apple.com) with JWT or p12 auth. So: the iOS path likely won't deliver in production either, even though it "works" locally against sandbox. This is the single biggest reason to consider this package a reference implementation rather than something to ship for new projects.
Certificate vs sandbox environment. ApnsAdapter distinguishes ENVIRONMENT_DEV vs ENVIRONMENT_PRODUCTION (sandbox gateway vs production). A dev certificate silently won't authenticate against production and vice versa. Symptom is an empty getFeedback() with zero sent devices — the handshake failed. Set the environment explicitly on the adapter, not just on PushManager, and log getFeedback() even on success.
Silent failures. PushManager::push() returns the processed Push objects, not a thrown exception, when an adapter returns a failure status. Always inspect each push's getFeedback() and getSentDevices() counts. A Push that reports zero sent and zero feedback means the connection-level failure — check error_log and stream errors.
Token and payload size limits. APNS payloads are capped at ~4KB (and 5KB for some paths); GCM/FCM at ~4KB. Building a huge Message with a data blob will fail per-token, not per-batch, and the error surfaces only in feedback. Keep payloads lean and let the device fetch details from your API.
Batch vs single-device. Sending all devices one-at-a-time is slower but gives precise per-token error mapping. Sending in batch is fast but a single malformed token is only visible in getFeedback(). A reasonable pattern: batch send, then re-send only the failed tokens individually so you know exactly which to prune.
Certificate path resolution. The config path is resolved relative to the Laravel base path — absolute paths and storage_path() strings both work, but a relative path will not. The passphrase must be a string, not null, even for unprotected certs (use ''). This trips up local-to-prod deploys because it's silently ignored.
PHP extensions. The APNS adapter depends on openssl + stream and the stream_socket_client() context options. In restricted environments (some shared hosts, Alpine images without openssl), it fails obscurely. Verify extension_loaded('openssl') before debugging anything else.
Extending it. The clean extension point is Sly\NotificationPusher\Adapter\BaseAdapter — subclass it, override getDefaultOptions() and push()/getFeedback(), and register the class in config('notification-pusher.adapters'). That's how you'd wire in an FCM HTTP v1 or APNS HTTP/2 adapter today without abandoning the package's Push/Device/Message domain model, which is genuinely nice and worth keeping.
Config caching. php artisan config:cache bakes the certificate paths and API keys in. If you rotate a key file on disk, clear the config cache or the old path persists — a classic "why is it still using the old cert" bug.
How can I help you explore Laravel packages today?