laravel-notification-channels/onesignal
OneSignal notification channel for Laravel. Send push notifications through OneSignal using Laravel’s notification system, with support for segments, filters, custom data, buttons, and platform-specific delivery options.
1. Install and wire credentials
composer require laravel-notification-channels/onesignal
Grab two values from the OneSignal dashboard (Settings → Keys & IDs): the App ID and the REST API Key. Put them in config/services.php — this package reads from services, not from its own config file:
'onesignal' => [
'app_id' => env('ONESIGNAL_APP_ID'),
'rest_api_key' => env('ONESIGNAL_REST_API_KEY'),
],
ONESIGNAL_APP_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
ONESIGNAL_REST_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
No vendor:publish step is normally required; the service provider binds the client and registers the channel. Verify the exact config keys against src/OneSignalServiceProvider.php in the version you pin — this is the single most common "it silently sends nothing" cause.
2. Teach your notifiable how to be reached
class User extends Authenticatable
{
use Notifiable;
public function routeNotificationForOneSignal($notification = null): array|string|null
{
return $this->onesignal_subscription_id; // or an array for multi-device
}
}
That column is populated by your mobile app calling OneSignal.login($externalId) / OneSignal.getDeviceState() and POSTing the subscription ID back to Laravel, or by registering the ID at app boot.
3. First notification
class OrderShipped extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public Order $order) {}
public function via($notifiable): array
{
return [OneSignalChannel::class];
}
public function toOneSignal($notifiable): OneSignalMessage
{
return OneSignalMessage::create()
->setSubject('Your order shipped')
->setBody("Order #{$this->order->number} is on its way.")
->setUrl(route('orders.show', $this->order))
->setData(['order_id' => $this->order->id, 'type' => 'order_shipped']);
}
}
Where to look first, in this order: the README (short, example-driven), src/OneSignalChannel.php (how routing and sending actually work — it's ~30 lines and worth reading), src/OneSignalMessage.php (the full setter surface), then src/OneSignalClient.php (the raw payload shape and error handling). Because the package is niche (294 stars, no downstream dependents), the source is the documentation for anything beyond the happy path.
First real use case: transactional, single-user push ("your driver is 5 minutes away", "invoice ready") triggered from a queued Laravel notification. This is where the channel shines: one class, one message builder, no device-side code changes.
Audience selection drives your architecture. Decide up front which of the three targeting modes you're in, because they route differently:
| Mode | How | Use for |
|---|---|---|
| Subscription / player ID | routeNotificationForOneSignal() returns ID(s) |
Transactional push to a known user |
| External user ID | message-level include_external_user_ids (alias-based in newer API) |
Same, but survives app reinstalls |
| Segment / filter / tag | message-level segment or filter setters | Broadcasts, marketing, "all users in region X" |
For the segment/filter mode the notifiable route becomes irrelevant — confirm in your pinned version whether the channel omits include_player_ids when the message already carries an audience, or whether you need to return null from the route.
Wrap the message builder, don't repeat it. Every notification in a mature app ends up with the same 6 setters. Centralise:
abstract class PushNotification extends Notification
{
protected function base(string $title, string $body, array $data = []): OneSignalMessage
{
return OneSignalMessage::create()
->setSubject($title)
->setBody($body)
->setData($data + ['app' => config('app.name')])
->setSound('default');
}
}
Multi-device users: return an array from routeNotificationForOneSignal() (or ->setIncludePlayerIds([...]) if you target per-message). OneSignal deduplicates delivery; your job is keeping the array clean — prune stale IDs when OneSignal reports recipients: 0.
On-demand notifications work without a model:
Notification::route(OneSignalChannel::class, $subscriptionId)
->notify(new InvoiceReady($invoice));
Note the key: when via() returns the channel class name, the route key must match that string exactly — 'onesignal' won't be picked up. This is a frequent silent no-op.
Rich payloads: buttons, images, and deep links are all message-level.
OneSignalMessage::create()
->setSubject('Approve expense?')
->setBody("€{$expense->amount} from {$expense->user->name}")
->addButton(OneSignalButton::create('approve')->label('Approve'))
->addButton(OneSignalButton::create('reject')->label('Reject'))
->setBigPicture($expense->receipt_url)
->setData(['expense_id' => $expense->id]);
Silent / data-only push: setContentAvailable(true) with no body. The value here is waking the app to refresh state, not showing anything — pair it with a short TTL so stale wakers don't pile up.
Scheduling: pick one clock. Either let OneSignal hold the message (send_after) or let Laravel hold it (->delay() on a queued notification). Doing both means two retry/skip semantics fighting each other, and cancelling becomes impossible.
Testing. Notification::fake() covers the decision layer:
Notification::fake();
$user->notify(new OrderShipped($order));
Notification::assertSentTo($user, OrderShipped::class, function ($n, $channels) {
return in_array(OneSignalChannel::class, $channels);
});
To test the message, call $notification->toOneSignal($user)->toArray() directly and assert on the payload — fast, no HTTP. To test the transport, bind a fake OneSignalClient in the container or inject a Guzzle MockHandler. Http::fake() will not intercept anything — the package talks to Guzzle directly, not Laravel's HTTP client. If you want Laravel-native faking, that's your strongest argument for wrapping the client yourself.
Integration tips. Keep the player-ID column on the user (or a devices table with user_id, subscription_id, platform, last_seen_at) rather than in a JSON blob — you'll want to delete rows on logout and on 410/recipients: 0. Dispatch notifications from queued listeners, never inline in a request that a mobile client is waiting on.
HTTP 200 does not mean delivered. OneSignal returns 200 with {"id":"","recipients":0,"errors":["All included players are not subscribed"]} for bad IDs. The package may hand you back a successful-looking response. Log recipients and errors on every send in staging; in production, treat recipients === 0 as a data-quality alarm.
routeNotificationForOneSignal() returning null/empty is a silent skip. The channel bails without throwing. If pushes "just don't arrive" for some users, log the route value before notify(). This one costs teams half a day, repeatedly.
Player/subscription IDs churn. They change on reinstall, restore-from-backup, and OS-level resets, and they're per-device. Either store them aggressively (update on every app launch) or move to external user IDs / aliases, which is where OneSignal's API has been heading. If you pin an old version of this package, check whether it still emits include_player_ids — that endpoint has been deprecated in OneSignal's newer API surface, and the package has had to track that.
Config caching bites. services.onesignal.* is read at container-build time. After changing env values on a server: php artisan config:clear && php artisan config:cache. A stale cached config produces "401 Unauthorized" that looks like a wrong key.
The REST API Key is not the App ID, and not the user-auth key. Mixing these up is the #1 first-day error. Also: keys are per-app, so multi-tenant setups need either multiple clients bound in the container or a runtime config swap before dispatch — and a runtime swap is not concurrency-safe under Octane. Prefer constructing the client yourself for tenant #2.
Platform setup is entirely in the dashboard. APNs certificates/keys, FCM credentials, web push VAPID keys — none of this is in the package, and a perfectly-formed Laravel notification will fail with recipients: 0 until the dashboard is green. Verify with OneSignal's own "Send test message" before blaming your code.
Payload limits are real. Custom data is capped (~4KB) and titles/bodies truncate on lockscreens. Don't ship image URLs inside data if you can use the dedicated picture setters — the OS renders those for free. And data is not a secret channel: anything you put there is readable on the device. Never send tokens, PII beyond what the lockscreen shows, or anything the user shouldn't see.
Queued notifications need ShouldQueue on the notification, not on the channel. The channel is synchronous. Combined with OneSignal's per-app rate limits, a broadcast to 50k users from a single job will throttle or time out — chunk it (Notification::send($chunk)) or use OneSignal's own segment API and skip Laravel entirely for true broadcasts. Knowing when not to use the channel is part of using it well.
Extension points are shallow, and that's fine. There's no events, no macros, no middleware. Your options are: (a) bind a subclass of OneSignalClient in the container for logging/retries, (b) extend OneSignalChannel and override send(), or (c) wrap the whole thing in your own PushGateway service and treat the package as an implementation detail. Pick (c) for anything long-lived — with zero dependents and a small star count, you want a seam that lets you swap providers without touching 40 notification classes.
Pin the version and read the changelog before upgrading. The package tracks OneSignal's API migrations (player IDs → aliases, v1 → v2 endpoints), so minor bumps have historically carried payload-shape changes. composer require laravel-notification-channels/onesignal:^X.Y with a note in the upgrade runbook, plus a single integration test that asserts your real payload reaches a staging app, is cheap insurance.
Verdict for day-to-day use: excellent when OneSignal is already your push vendor and you want transactional push in ~20 lines. Thin, predictable, low-magic. Weak as a general abstraction — no queueing, no retries, no observability, an awkward test story — so wrap it, log the delivery metrics yourself, and don't expect it to grow features you'd get from a first-party integration.
How can I help you explore Laravel packages today?