Search by

alexhackney / laravel-ntfy

alexhackney

ntfy notifications channel and client for Laravel

Package info

github.com/alexhackney/laravel-ntfy

pkg:composer/alexhackney/laravel-ntfy

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-10-02 14:58 UTC

This package is auto-updated.

Last update: 2026-10-02 15:07:38 UTC


README

Latest Version on Packagist Software License

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

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:

  1. ->topic('...') on the message
  2. the notifiable's route: routeNotificationForNtfy() on a model, or Notification::route('ntfy', '...')
  3. 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.