alexhackney / laravel-ntfy
ntfy notifications channel and client for Laravel
Requires
- php: ^8.3
- illuminate/http: ^12.0|^13.0
- illuminate/notifications: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.10
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Send Laravel notifications to ntfy, the push service for phones and desktops.
This package sends to ntfy and nothing else. Alert levels, topic tiers, deduplication, rate limiting, retries and recipient policy belong in your application.
Contents
- Installation
- Setting up ntfy
- Usage
- Message options
- Actions
- Update in place with sequence ids
- Using the client without notifications
- Handling failures
- Testing
- Changelog
- Contributing
- Security
- Credits
- License
Installation
composer require alexhackney/laravel-ntfy
Requires PHP 8.3+ and Laravel 12 or 13. The service provider is auto-discovered. There is no
runtime dependency beyond illuminate/*; requests go through Laravel's Http facade.
Setting up ntfy
Add the server details to config/services.php:
'ntfy' => [ 'url' => env('NTFY_URL', 'https://ntfy.sh'), 'token' => env('NTFY_TOKEN'), 'topic' => env('NTFY_TOPIC'), 'timeout' => env('NTFY_TIMEOUT', 5), ],
| Key | Default | Meaning |
|---|---|---|
url |
https://ntfy.sh |
Server base URL. Point it at your own server if you self-host. |
token |
none | Access token (tk_...), sent as a bearer token. Omit for anonymous publishing. |
topic |
none | Default topic, used when neither the message nor the notifiable names one. |
timeout |
5 |
Request timeout in seconds. The connect timeout is fixed at 2 seconds. |
Notes:
- On the public ntfy.sh server a topic name is the only secret protecting it. Reserve your topics with an account (they become deny-all to everyone else) and publish with a token.
- Use a separate token per application so each can be revoked on its own.
- There is no published config file; the package reads
services.ntfy.
Usage
Add ntfy routing to your notifiable (optional) and a toNtfy() method to the notification:
use NotificationChannels\Ntfy\NtfyChannel; use NotificationChannels\Ntfy\NtfyMessage; use NotificationChannels\Ntfy\Priority; class DeployFailed extends Notification { public function via(object $notifiable): array { return [NtfyChannel::class]; } public function toNtfy(object $notifiable): NtfyMessage { return NtfyMessage::create('Deploy of main failed on web-2') ->title('Deploy failed') ->priority(Priority::High) ->tags(['rotating_light']) ->click('https://example.com/deploys/123'); } }
toNtfy() may return an NtfyMessage, a plain string (used as the message body) or null
(nothing is sent).
Choosing the topic
The first of these that is not null or blank wins:
->topic('...')on the message- the notifiable's route:
routeNotificationForNtfy()on a model, orNotification::route('ntfy', '...') services.ntfy.topic
Later sources are only consulted when earlier ones give nothing, so an explicit message topic never calls your route method.
class User extends Authenticatable { use Notifiable; public function routeNotificationForNtfy(Notification $notification): string { return $this->ntfy_topic; } } Notification::route('ntfy', 'ops-alerts')->notify(new DeployFailed);
The topic "0" is a valid topic at every level. If no topic is found, sending throws
CouldNotSendNotification::missingTopic().
Message options
| Method | ntfy field | Notes |
|---|---|---|
NtfyMessage::create($body) / message($body) |
message |
Truncated to 4,096 bytes on a UTF-8 boundary. |
title($title) |
title |
Truncated to 1 KB on a UTF-8 boundary. |
priority(Priority|int) |
priority |
Priority::Min (1), Low, Default (3), High, Urgent (5). An int outside 1-5 throws InvalidArgumentException. |
tags(array) |
tags |
Emoji short codes or plain tags. Over 512 bytes in total throws InvalidArgumentException. |
click($url) |
click |
Opened when the notification is tapped. |
markdown(bool) |
markdown |
Render the body as Markdown. The iOS app (1.7) shows the raw text instead. |
icon($url) |
icon |
Notification icon. |
attach($url, $filename) |
attach, filename |
Attach a file by URL. |
delay($when) |
delay |
For example 30m or tomorrow, 10am. |
sequenceId($id) |
sequence_id |
See below. |
topic($topic) |
Per-message topic override. Never part of the JSON body; the client adds it. | |
viewAction(), httpAction(), copyAction() |
actions |
See below. |
Null and empty fields are omitted from the request.
The request is sent as JSON with Unicode left unescaped, and its finished size is checked
against ntfy's default 8,192 byte request limit. A body over that (for example, several large
httpAction() bodies) throws CouldNotSendNotification before any request is made; it is
permanent, so isTransient() is false. A self-hosted server with a higher limit can override
NtfyClient::MAX_JSON_BYTES in a subclass.
Actions
A message can carry up to three action buttons. A fourth throws InvalidArgumentException.
NtfyMessage::create('Build 482 needs approval') ->viewAction('Open build', 'https://ci.example.com/482') ->httpAction('Approve', 'https://ci.example.com/482/approve', 'POST', ['X-Token' => '...'], clear: true) ->copyAction('Copy build id', '482');
Each takes a trailing clear: true to dismiss the notification when the button is tapped.
Update in place with sequence ids
Publishing again with the same sequence id replaces the earlier notification instead of buzzing a second time (ntfy server 2.16+). That is how "feed down" becomes "feed recovered". ntfy documents this for Android and the web app; this package has not verified either. On the iOS app (1.7, checked live against ntfy.sh on 2026-10-02) it does not work: both messages stay in the list, and the clear and delete events appear to show up as blank rows instead of removing anything. Treat the replace as a nicety on iOS, and make sure a sequence-id update still reads correctly as a second notification there:
NtfyMessage::create('Feed is down')->sequenceId('feed-42'); NtfyMessage::create('Feed recovered')->sequenceId('feed-42');
Dismiss or remove it with the client:
$client->clear('ops-alerts', 'feed-42'); // marks it read and dismisses it $client->delete('ops-alerts', 'feed-42'); // removes it
Using the client without notifications
NtfyClient is bound as a singleton from services.ntfy. Use it directly for work that is not a
Laravel notification, such as a scheduled summary:
use NotificationChannels\Ntfy\NtfyClient; use NotificationChannels\Ntfy\NtfyMessage; $result = app(NtfyClient::class)->publish('ops-alerts', NtfyMessage::create('Nightly summary')); $result['id']; // ntfy's message id $result['time']; // unix time ntfy accepted it
publish() returns ntfy's decoded response. NtfyChannel::send() returns the same array, so
Laravel hands it to the NotificationSent event as $event->response.
Handling failures
Failures are never swallowed. Any failure throws NotificationChannels\Ntfy\Exceptions\CouldNotSendNotification
(an error response, a connection error, a missing topic or an oversized body), and the channel dispatches Laravel's NotificationFailed event (channel ntfy, with
['exception' => $e] as data) before rethrowing.
try { $client->publish('ops-alerts', $message); } catch (CouldNotSendNotification $e) { $e->status(); // HTTP status, or null when there was no response $e->ntfyErrorCode(); // ntfy's own error code (for example 40401), or null $e->isTransient(); // true for 408, 429, 5xx and connection errors; false for other 4xx, // a missing topic and an oversized request body }
isTransient() is what makes queued notifications retry sensibly: let transient errors
propagate so the queue retries, and handle permanent ones (a bad token or topic) yourself.
Retry and swallow policy belongs to your app. A common pattern is to bind the channel to a
subclass in a service provider:
$this->app->bind(NtfyChannel::class, AppNtfyChannel::class);
send() and the constructor are not final, so a subclass can catch the exception, log permanent
failures and rethrow transient ones.
The topic is never logged
On a public server the topic is the secret, and ntfy's error body can echo it back. So:
- The topic (raw and URL-encoded) and the token are replaced with
<topic>and<token>in every exception message the package builds. - The message is built from the status, ntfy's error code and a redacted, truncated body. It never quotes the raw response body.
- The package does no logging of its own.
One caveat: for connection errors the original exception is kept as getPrevious() for its stack
trace, and its message is not redacted. If you log the whole exception chain, redact it yourself.
Testing
Nothing in this package needs a live server to test. Fake the HTTP layer:
Http::fake(['ntfy.sh/*' => Http::response(['id' => 'abc', 'time' => 1700000000, 'topic' => 'ops-alerts'])]); $user->notify(new DeployFailed); Http::assertSent(fn ($request) => $request['topic'] === 'ops-alerts' && $request['title'] === 'Deploy failed');
Or fake the notification layer and assert on the channel:
Notification::fake(); $user->notify(new DeployFailed); Notification::assertSentTo($user, DeployFailed::class, fn ($n, $channels) => $channels === [NtfyChannel::class]);
To run this package's own checks:
composer test # Pest composer analyse # PHPStan level 8 with Larastan composer format # Pint vendor/bin/pest --coverage --min=100 # needs pcov or xdebug; CI enforces 100% composer hooks # once per clone: Pint pre-commit hook
There is also an opt-in group that talks to a real ntfy server. It is skipped unless you set
NTFY_LIVE_TOKEN and NTFY_LIVE_TOPIC, and it never prints either:
NTFY_LIVE_TOKEN=tk_... NTFY_LIVE_TOPIC=my-topic vendor/bin/pest --group=live
It sends real notifications to that topic, so watch your phone while it runs.
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security
If you discover any security related issues, please email alex@alexhackney.com instead of using the issue tracker.
Credits
License
The MIT License (MIT). Please see License File for more information.