solvecrew/expo-notifications-bundle
A Laravel bundle for Expo push notifications: manage device tokens, send notifications to iOS and Android through Expo, and add configurable push messaging to your app with a simple API.
First, calibrate expectations: despite the Laravel-oriented framing, solvecrew/expo-notifications-bundle is a Symfony bundle, not a Laravel package. There is no ExpoNotificationsServiceProvider, no config/expo-notifications.php, and php artisan package:discover will do nothing with it. Your job as a Laravel dev is to decide whether to (a) pull in the bundle and bridge its services into Laravel's container, or (b) lift the handful of value objects/HTTP calls you actually need and implement against Expo's API directly.
Minimal steps to evaluate:
composer require solvecrew/expo-notifications-bundle
Then read, in this order:
src/ — the core classes (notification/message value objects, the sender/client service, any exception classes). These are the parts that matter and are often framework-agnostic.DependencyInjection/Configuration.php — this is the authoritative list of config keys (access token, project ID, HTTP client options). Symfony bundles document config here, not in a README.Resources/config/services.* (.yaml/.xml) — shows how services are wired and what interfaces/aliases exist for injection.composer.json — confirm the Symfony version constraints and whether symfony/http-client is required.First use case: send a single push to one Expo token.
// Assuming you've instantiated/wired the bundle's sender class manually
$message = (new NotificationMessage())
->setTo('ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]')
->setTitle('Order shipped')
->setBody('Your parcel is on the way.')
->setData(['order_id' => 123]);
$response = $sender->send($message); // POSTs to https://exp.host/--/api/v2/push/send
If you can get that one call working against a real ExponentPushToken, you understand the package. Everything else is token management, batching, and receipts.
Bridge the Symfony service into Laravel. The cleanest integration is a thin service provider that instantiates the bundle's sender (or wraps it) and binds it as a singleton, rather than trying to load the bundle's DI extension:
$this->app->singleton(ExpoSender::class, function ($app) {
return new ExpoSender(
client: new \Symfony\Component\HttpClient\HttpClient(...),
accessToken: config('services.expo.access_token'),
);
});
Keep a config/services.php entry for the Expo access token and, if your SDK version requires it, the projectId.
Store tokens in Eloquent, not the bundle. The bundle has no opinion about persistence (and should not). Create an expo_tokens table with a unique index on the token, user_id, created_at, and last_used_at. This is what lets you do the two things Expo actually requires: dedupe and prune.
Send from queued jobs, not controllers. Batch dispatch is the correct shape:
class SendExpoPush implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(public int $userId, public array $payload) {}
public function handle(ExpoSender $sender): void
{
$tokens = ExpoToken::where('user_id', $this->userId)->pluck('token');
foreach ($tokens->chunk(100) as $chunk) { // Expo hard limit is 100/request
$response = $sender->sendMultiple($chunk->map(
fn ($t) => (new NotificationMessage())
->setTo($t)
->setTitle($this->payload['title'])
->setBody($this->payload['body'])
->setData($this->payload['data'] ?? [])
)->all());
$this->handleTickets($response);
}
}
}
Treat the send response as tickets, not delivery. Expo's /push/send returns {data: [{status: "ok", id: "..."}]} — the id is a receipt ID. Persist those IDs in a push_receipts table keyed by message, then run a scheduled command every ~15 minutes that calls /push/getReceipts with the outstanding IDs. Only there do you learn about DeviceNotRegistered or MessageTooBig.
// app/Console/Commands/CheckExpoReceipts.php — schedule every 15 minutes
Schedule::command('expo:check-receipts')->everyFifteenMinutes();
On DeviceNotRegistered, delete the token row immediately. On InvalidCredentials, fail loudly — your server credentials are wrong and no amount of retrying will help.
Mirror Laravel's notification ergonomics. If your team already uses Notification classes, wrap the bundle's sender in a channel (ExpoChannel::send()) so push becomes just another via() option. This keeps call sites idiomatic and hides the Symfony-shaped API.
Container mismatch is the #1 failure mode. Symfony's autowiring will not resolve in Laravel, and the bundle expects symfony/http-client (usually). Either provide the client yourself via the bridge provider, or swap in a Laravel/Guzzle adapter that implements whatever interface the sender's constructor type-hints. Check the constructor signature before writing the binding.
Config keys live in Configuration.php, not in docs. Symfony's TreeBuilder is often the only place the environment variable names and defaults are written down. Read it before you guess at EXPO_ACCESS_TOKEN vs EXPO_TOKEN.
Validate the token format client-side. Tokens look like ExponentPushToken[...] or ExpoPushToken[...]. Sending a malformed one gets you a per-message error inside an otherwise 200 OK response — the HTTP status is not the result.
A 200 response is not success. Expo returns HTTP 200 with a data array where individual entries can have status: "error" and an error code. Log the raw response body during initial integration; treat "all messages ok" as a post-send assertion, not an HTTP-level check.
Rate limits are per-project and low. Expo's documented limit is roughly 600 requests/second per project and a small number of messages per request. Your chunking strategy matters more than your retry strategy; back off on 429s rather than hammering.
Receipts expire. Expo purges receipt data after a day or so. If your scheduled command is down longer than that, you lose visibility into failures — so schedule it reliably and clean up old push_receipts rows.
Bounded retries only. Retry on 429/5xx with exponential backoff (Laravel's $backoff on the job works); never retry on DeviceNotRegistered or InvalidCredentials — those are terminal and will just burn queue throughput.
Debugging tips: Http::fake() does not intercept the Symfony client — mock the Symfony HttpClientInterface instead, or inject a fake client into your bridge. Log the ticket IDs you persist; that's the only way to correlate a user complaint ("push didn't arrive") back to a receipt error days later.
Extension points: the useful surfaces are the sender service (swap the HTTP client for testing/observability) and the message value object (subclass or compose to add sound, badge, channelId, priority — check what setters actually exist before assuming parity with Expo's full payload schema).
Version pinning matters. With 20 stars and zero dependents, this package has no ecosystem pressure to stay current with Symfony/Laravel releases. Pin it, run your test suite, and be prepared to fork the one or two classes you depend on if Symfony 7/8 compatibility drifts. For a production Laravel app, forking ten lines of an HTTP POST is frequently the lower-risk choice.
How can I help you explore Laravel packages today?