laravel-notification-channels/pushover
Send Laravel notifications through Pushover with this dedicated notification channel. Deliver real-time push alerts to iOS, Android, and desktop devices using Pushover's simple API and your Laravel app's existing notification system.
Install and wire up the token
composer require laravel-notification-channels/pushover
php artisan vendor:publish --provider="NotificationChannels\Pushover\PushoverServiceProvider"
You now have config/pushover.php. It holds a single value you care about: the application token (PUSHOVER_TOKEN in .env). Get it from https://pushover.net/apps/build — this is the app, not the recipient.
The second credential is the user key, and it belongs to each recipient, not the config. Users find theirs on their Pushover dashboard.
Add a route to your notifiable
// app/Models/User.php
public function routeNotificationForPushover()
{
return $this->pushover_user_key;
}
Store pushover_user_key as a nullable column and let users paste it in a settings screen (with a "send test" button — it takes five lines and saves hours of support).
First notification
use NotificationChannels\Pushover\PushoverChannel;
use NotificationChannels\Pushover\PushoverMessage;
class InvoicePaid extends Notification
{
public function via($notifiable)
{
return [PushoverChannel::class];
}
public function toPushover($notifiable)
{
return PushoverMessage::create('Invoice #1042 was paid — $249.00')
->title('Payment received')
->sound('incoming')
->url(route('invoices.show', 1042), 'Open invoice');
}
}
$user->notify(new InvoicePaid($invoice));
Where to look first
README.md for the builder surface (title, sound, url, device, priority helpers, TTL).src/PushoverMessage.php — the single source of truth for what the payload can contain. The README lags the class; read the class.src/PushoverChannel.php — shows exactly which route method and key string it resolves, which is what you need for on-demand notifications.tests/ — small, but the fastest way to see expected payloads.Best first use case: internal/ops alerts. Pushover's whole value is a persistent, high-priority, audible alert on a phone that isn't an email in a crowded inbox. Deploy failures, queue backlog, failed payment webhooks, disk thresholds — send those to the on-call engineer before you start wiring it into user-facing product flows.
Route defensively, then decide in via()
A user without a key will fail at send time. Filter first:
public function via($notifiable)
{
return $notifiable->pushover_user_key ? [PushoverChannel::class] : [];
}
This keeps notify() calls safe to fire in loops over mixed user sets, and avoids try/catch noise around a missing credential.
On-demand notifications (no model changes)
For one-off alerts, admin tools, or "notify whoever is on call":
Notification::route('Pushover', $userKey)->notify(new DeployFailed($deploy));
Use exactly the route key string the channel passes to routeNotificationFor() — check PushoverChannel::send() once and standardize it in a constant or helper so a future package bump can't silently break your alerting.
Priority is the whole game — pick deliberately
->lowPriority() — priority -1, silent, no vibration. Use for FYI chatter (someone commented, a report is ready).If you need acknowledgement tracking (did a human tap "Acknowledge"?), the API returns a receipt you must poll at /1/receipts/{receipt}.json. The channel wrapper is thin around send, so plan on either a small scheduled job hitting the receipts endpoint yourself or extending PushoverChannel to return the response.
TTL: pass a Carbon instance, not a hand-rolled timestamp
Since 5.0.1 the ttl setter accepts either an integer number of seconds or a Carbon/CarbonImmutable instance:
PushoverMessage::create('Deploy failed — rollback required')
->emergency(60, 300) // retry every 60s, expire after 5 min
->ttl(now()->addMinutes(30));
Prefer the date form in application code — now()->addMinutes(30) reads correctly and can't be confused for a Unix timestamp. TTL only meaningfully applies to emergency-priority messages (it caps how long Pushover keeps retrying); setting it on a normal-priority message is a no-op you shouldn't rely on. Keep emergency() retry/expiry and ttl() consistent rather than picking three unrelated numbers.
Keep body copy short, put detail behind url()
Pushover bodies are meant to be glanced at. Pattern that works well:
PushoverMessage::create("Queue backlog: {$count} jobs\nOldest: {$age} min")
->title('Horizon alert')
->url(config('app.url').'/horizon/failed', 'Inspect');
url() + title turns the alert into a one-tap jump into the relevant dashboard, which is the difference between a useful channel and a noisy one.
Queue them
class DeployFailed extends Notification implements ShouldQueue
{
public $tries = 3;
public $backoff = [10, 60];
}
Pushover is fast, but a webhook fan-out or a loop over a team on-call list should never block a request. Queuing also gives you free retries when Pushover rate-limits you.
Team/on-call fan-out
For "notify the whole on-call rotation," loop over keys and send individual notifications — Pushover is per-user, there is no group concept:
collect($rotation->keys)->each(
fn ($key) => Notification::route('Pushover', $key)->notify($alert)
);
Wrap that in a queued job with a small usleep() or chunking if the rotation is large, and consider one aggregated message per person rather than N messages.
Testing
Notification::fake();
$this->post('/webhooks/stripe', $payload);
Notification::assertSentTo($user, InvoicePaid::class);
Notification::fake() is enough for 95% of your suite — it prevents real pushes from CI. Only reach for Http::fake() on the Pushover endpoints when you're specifically asserting payload shape (priority, sound, supplementary URL, TTL), because the builder is where your bugs will live.
Log deliveries if alerting matters
For ops alerts, append a row to a notifications_log table in a NotificationSent listener. When someone says "I never got paged," you want to distinguish between we didn't send, Pushover rejected, and the user's phone was do-not-disturb.
Token vs. key confusion is the #1 error. PUSHOVER_TOKEN is the app; the route value is the recipient. A 400 with user identifier is invalid means the user key is wrong or empty — verify against the recipient's dashboard, not your config.
Empty token in CI. If PUSHOVER_TOKEN isn't set, the channel throws when actually sending. Notification::fake() solves it, but also put a dummy value in phpunit.xml so any un-faked path degrades into a fake HTTP call rather than an exception about a missing config value.
Config cache. Classic, still bites: after editing .env, run php artisan config:clear (or config:cache in deploy). A stale cached empty token looks exactly like a code bug.
Rate limits are real and blunt. Pushover enforces per-app monthly quotas and per-user burst limits; over-limit requests come back as HTTP 429. Because the channel throws on error, an alert storm can cascade into failed jobs. Mitigate with: queued notifications, $tries/$backoff, and a self-imposed throttle (cache-based lock per user key) on high-volume paths. For emergency priority, the repeat-until-acknowledged behavior can amplify a loop bug into hundreds of pushes — test priority escalation with a single personal device before pointing it at a rotation.
Message length truncation. Titles and bodies have hard caps; long bodies are rejected or trimmed by the API. Truncate at the source (Str::limit($body, 1000)) rather than discovering it in production via a failed notification on your longest error message.
lowPriority() silences everything. It sets priority -1, which means no sound and no vibration. Teams that adopt it for "less noise" are often surprised they stop noticing alerts entirely. Use it for informational messages, never for anything time-bound.
Emergency priority needs sane parameters. Very short retry intervals and very long expiry windows are rejected by the API. Use realistic values (retry measured in tens of seconds, expiry in minutes) and always pair it with a callback/acknowledgement path — otherwise the phone rings forever. If you're on 5.0.0 or older, remember ttl there only takes an integer; the Carbon-accepting signature landed in 5.0.1, so pin ^5.0.1 if you want to write ->ttl(now()->addMinutes(30)).
Sounds must be from Pushover's list. An invalid sound value fails validation at the API, not in PHP. Keep a small enum/const of the sounds you actually use (pushover, incoming, persistent, none) instead of passing free strings from user input.
HTML/markdown-ish formatting is limited. Pushover supports basic HTML tags with an opt-in flag. If the builder in your installed version doesn't expose it, don't fight it — extend PushoverMessage with your own setter that adds the payload key, or drop formatting and rely on title + url() for structure. Body formatting is rarely worth custom code.
Attachments/images go through the same multipart API and are useful for screenshot-based alerts (a rendered chart, a failed-page capture), but they inflate payload size and failure modes. Treat them as an enhancement, not a dependency — your alert must still be readable without the image.
Catch the exception where the flow is user-facing. Wrap notify() in a try/catch for CouldNotSendNotification when the push is secondary to the action (invoice paid → push is a nicety). Let it bubble only when the push is the product (paging).
Version pinning and maintenance. This is a small community package (roughly 61 stars, no dependents in the ecosystem). It's a thin, well-scoped wrapper, so the risk is low — pin it in composer.json (5.0.1 is a drop-in over 5.0.0: one signature widening on ttl, no breaking changes), read composer.json's Laravel constraint before a framework upgrade, and know your escape hatch: Pushover's entire send API is one form-encoded POST to https://api.pushover.net/1/messages.json with token, user, message, and optional title/priority/sound/url/ttl. If the package ever lags a Laravel release, a 30-line custom channel or a single Http::asForm()->post(...) call replaces it in an afternoon. Don't build elaborate abstractions on top of it.
Extension points worth knowing. The service provider registers the client/channel binding; override it in your own provider if you need a custom HTTP client (timeouts, proxies, request logging) or want to point at a fake endpoint in tests. Subclass PushoverMessage for any payload field the builder doesn't cover — the channel just sends the payload array, so a subclass is a low-risk extension.
How can I help you explore Laravel packages today?