onesignal/onesignal-php-api
A PHP client for the OneSignal API that enables push notification delivery and management of users, segments, templates, devices, and apps from PHP or Laravel applications. Provides generated API methods and models for quick integration.
Install the client via Composer; it pulls in Guzzle and a generated set of request/response models:
composer require onesignal/onesignal-php-api
Before writing integration code, open vendor/onesignal/onesignal-php-api/src once. This is a generated SDK, so the exact namespaces (Onesignal\OneSignal\..., Onesignal\Client\..., or a legacy OneSignal\OneSignal facade depending on the release you pinned) determine everything. Read composer.json for the version and check the Api/ and Model/ folders for the endpoint class that matches the resource you need — usually NotificationsApi, DevicesApi, SegmentsApi, and TemplatesApi.
Store credentials in .env and expose them through config/services.php:
// config/services.php
'onesignal' => [
'app_id' => env('ONESIGNAL_APP_ID'),
'api_key' => env('ONESIGNAL_REST_API_KEY'),
],
ONESIGNAL_APP_ID=00000000-0000-0000-0000-000000000000
ONESIGNAL_REST_API_KEY=your-rest-api-key
Bind the generated configuration as a singleton so you are not rebuilding a Guzzle stack on every request:
// app/Providers/AppServiceProvider.php
use Onesignal\OneSignal\Configuration; // verify against vendor/
$this->app->singleton(Configuration::class, function () {
return Configuration::getDefaultConfiguration()
->setApiKey('Authorization', 'Basic ' . config('services.onesignal.api_key'))
->setHost('https://api.onesignal.com');
});
Your first use case should be "send a push to all subscribed users" — it exercises auth, the app ID path parameter, the notification model, and error handling in one shot:
$notification = (new Notification())
->setAppId(config('services.onesignal.app_id'))
->setContents(['en' => 'Hello from Laravel'])
->setHeadings(['en' => 'Deploy complete'])
->setIncludedSegments(['Subscribed Users']);
$response = app(NotificationsApi::class)->createNotification($notification);
// $response->getId(), $response->getRecipients()
If you are on a legacy release with the hand-written client, the equivalent is OneSignal::notifications()->create(...) with fluent builders — but pin the version and treat the two APIs as different packages.
Wrap the SDK in one service class. Never reference generated classes from controllers or jobs. A thin OneSignalService with intention-revealing methods (sendToUser, sendToSegment, sendTemplate) keeps the generated-model churn contained when you upgrade.
final class OneSignalService
{
public function __construct(private NotificationsApi $api) {}
public function sendToUser(string $externalId, string $title, string $body, array $data = []): string
{
$notification = (new Notification())
->setAppId(config('services.onesignal.app_id'))
->setIncludeAliases(['external_id' => [$externalId]])
->setTargetChannel('push')
->setHeadings(['en' => $title])
->setContents(['en' => $body])
->setData($data)
->setIdempotencyKey(Str::uuid()->toString());
return $this->api->createNotification($notification)->getId();
}
}
Always send from a queued job. Notifications are an outbound network call; they belong behind a queue with $tries = 3, backoff(), and retryUntil(). This gives you retries on 429/5xx and keeps the request cycle fast.
final class SendPushNotification implements ShouldQueue
{
public int $tries = 3;
public array $backoff = [5, 30, 120];
public function __construct(public string $userId, public string $title, public string $body) {}
public function handle(OneSignalService $oneSignal): void
{
$oneSignal->sendToUser($this->userId, $this->title, $this->body);
}
}
Choose your targeting primitive deliberately. Use include_aliases with external_id when you control identity (the OneSignal SDK's login() populates it), include_player_ids for one-off subscription IDs, included_segments for broadcasts, and filters for tag/attribute combinations. Mixing included_segments and include_aliases in one request is rejected by the API.
Use templates for anything content-managed. Create the template in the OneSignal dashboard, ship only setTemplateId(...) plus setCustomData([...]) from PHP. This lets marketing edit copy without a deploy and keeps notification text out of your repo.
Multi-tenant apps. If you run per-customer OneSignal apps, don't use a singleton. Build a small registry keyed by tenant that constructs a Configuration + NotificationsApi pair, cached in Cache::remember for the request lifecycle.
Idempotency keys are cheap insurance. Generate a UUID per logical send and pass it through. If a job retries after an ambiguous timeout, OneSignal will deduplicate instead of double-notifying the user.
Test with a fake, not a sandbox. Inject a mock of your OneSignalService in feature tests, and for the service itself assert the built Notification payload by mocking the API class with Mockery. If your wrapper goes through Laravel's HTTP client, Http::fake() works too.
Deep links travel in data, not the body. Put ['route' => '/orders/42'] in setData() and let the mobile app route on notification tap. Keep the key names stable — they become part of your mobile contract.
Authorization: Basic, not Bearer. OneSignal uses HTTP Basic with the REST API key as the username and an empty password. Most docs snippets show Key xxx (the legacy header). Prefixing with Bearer produces a confusing 401 that looks like a key problem but is a scheme problem.
The app ID is a path parameter, not just a body field. On the v2 API it appears in the URL and in the notification payload. Build it from config once; a mismatched ID between the two produces 404 app not found in one call and 400 in another.
ApiException carries the body — log it. Generated clients throw ApiException with getCode(), getResponseHeaders(), and getResponseBody(). The human-readable error ("errors": ["Notification content must not be empty"]) is in the body, not the message. Always log the body; otherwise every failure looks like "400 Bad Request".
contents and headings need an en key. The API expects a localized map, so ['en' => '...'] is the minimum. Passing a plain string works in some versions and silently fails in others.
Rate limits are per-app and per-endpoint. Roughly 50 notifications/second sustained, lower on some endpoints. With a queue this mostly means setting a RateLimited middleware or a per-queue throttle; without one, a Notification::send() to 10k users will fan out into thousands of 429s.
Batches cap around 20,000 subscription IDs per request. Chunk large include_player_ids arrays before calling, or switch to aliases/segments which are cheaper and more scalable.
send_after is UTC ISO-8601. The API has no concept of your app timezone. Convert with ->setSendAfter($when->utc()->toIso8601String()) and remember that scheduling is best-effort, not exact.
Empty included_segments is not the same as "everyone". Omitting targeting entirely is rejected. Default to ['Subscribed Users'] explicitly in your wrapper so a mis-built notification fails loudly rather than blasting your entire userbase.
Version pinning matters more than usual. This SDK is generated from the OneSignal OpenAPI spec and its namespaces and method signatures have changed between major releases. Pin with a caret constraint and read the changelog on upgrade; do not run composer update blind on this package.
Debug by dumping the request, not the response. Attach a Guzzle middleware (->push($handler, Middleware::mapRequest(...))) or enable the configuration's debug flag to see the exact JSON being sent. Half of all "it says 400" issues are a field that is null in the payload because the setter was never called.
Never ship the REST API key client-side. It grants full push access to the app. Only the OneSignal mobile SDK belongs in the app; everything in this package is server-side only.
Local development: don't send real pushes. Gate the service behind a NotificationTransport interface and bind a LogTransport in local/testing so developers see payloads in storage/logs/laravel.log instead of waking up their own phones.
How can I help you explore Laravel packages today?