wijourdil/ntfy-notification-channel
Send Laravel notifications through ntfy, the simple HTTP-based push service. This channel delivers messages to your phone or desktop via public or self-hosted ntfy servers using Laravel's familiar notification system.
Install and wire it up
composer require wijourdil/ntfy-notification-channel
php artisan vendor:publish --provider="Wijourdil\NtfyNotificationChannel\NtfyNotificationChannelServiceProvider"
Set the basics in .env:
NTFY_SERVER=https://ntfy.sh
NTFY_TOPIC=ops-alerts-a7f3c9e1 # random, not guessable
# NTFY_TOKEN=tk_xxx # only if the topic is protected
Where to look first
config/ntfy.php — this is the whole package's surface area (server URL, default topic, auth). Read it top to bottom; it's short.Channels/NtfyChannel.php) — confirms exactly which method on the notification it calls (toNtfy) and what type it expects back (NtfyMessage).NtfyMessage — the fluent builder. Everything you can do with ntfy (title, priority, tags, click, actions, attachment) lives here.First use case: alert yourself when something breaks
Add a "self" notifiable (or use Notification::route() for on-demand):
use Illuminate\Support\Facades\Notification;
use Wijourdil\NtfyNotificationChannel\Messages\NtfyMessage;
Notification::route('ntfy', 'ops-alerts-a7f3c9e1')
->notify(new DeploymentFinished($release));
And in the notification:
public function via($notifiable): array
{
return ['ntfy'];
}
public function toNtfy($notifiable): NtfyMessage
{
return NtfyMessage::create()
->topic('ops-alerts-a7f3c9e1')
->title('Deploy finished')
->message("Release {$this->release} is live")
->priority(3);
}
That single path — job failed, exception, deploy done — is where this package earns its keep.
Routing: three ways, pick one per use case
routeNotificationForNtfy() to a User/Team model returning the topic string. Good when each tenant has its own topic.NTFY_TOPIC and drop the topic from toNtfy(). Good for a single ops channel.Notification::route('ntfy', $topic) for one-offs where there's no model (failed jobs, health checks, external webhooks).Multiple channels, same notification
public function via($notifiable): array
{
return $notifiable->wants_push
? ['mail', 'ntfy']
: ['mail'];
}
Keep ntfy for the high-signal stuff (failures, security events) and let email/Slack handle the noise. ntfy's value is a phone buzz, so route accordingly.
Priority is your triage lever
ntfy priorities are 1–5 (1 = min, 3 = default, 5 = urgent, bypasses DND on Android). Map them to real semantics in your app:
->priority(match (true) {
$this->isSecurityEvent() => 5,
$this->isFailure() => 4,
default => 3,
});
Action buttons turn alerts into links
->click(url("/admin/orders/{$this->order->id}"))
or richer ->actions([...]) with a "view" button and a "mark resolved" URL action. This is what makes ntfy better than a plain push for internal tooling — the notification opens the right admin page.
Tags as lightweight metadata
->tags(['warning', 'rotating_light']) renders emoji on the client and gives you a visual cue without parsing text. Common: warning, rotating_light, white_check_mark, rocket.
Queued notifications + retries
If your notification implements ShouldQueue, via() is still evaluated on the worker, and a failing ntfy call will retry on your queue's backoff. Set $tries low — ntfy is best-effort, not your system of record. Pair with failed() handlers so a dead channel doesn't mask the original failure.
Scheduled health check pattern
Schedule::call(fn () => Notification::route('ntfy', config('ntfy.topic'))
->notify(new HeartbeatNotification()))
->dailyAt('08:00');
A daily "still alive" message is the cheapest uptime signal you'll get out of this package.
Public topics on ntfy.sh are readable by anyone who knows the name. This is the number one trap. Topic names are not secrets-protected by obscurity in any real sense — use a long random string, or better, protect the topic with auth (access token / basic auth) and store it in config encrypted. For anything internal, self-host ntfy and point NTFY_SERVER at it. Never put customer PII in a message body.
Verify the actual method names before you copy from a blog post. This package has ~23 stars and no dependents, so community examples are thin. The README and the NtfyMessage class are the source of truth; class names in snippets above match the package's conventions but confirm the namespace against what composer require dropped in vendor/.
HTTP client behaviour is inherited from Laravel's HTTP client. That means a dead ntfy server will hang for your default timeout. Set a short one — Http::timeout(5)->retry(2, 200) — or, if the package doesn't expose it, configure globally via Http::globalMiddleware. A notification channel must never block a request cycle for 30 seconds.
Message size limits are real. ntfy caps message bodies around 4 KB and attachments much lower. If you're tempted to ->message(json_encode($payload)), stop — send a summary and a ->click() URL to the detail.
Priority and header encoding. UTF-8 in titles/messages works, but if you hit odd rendering on a specific client, check whether the package is sending Title vs X-Title headers. Non-ASCII subject lines are the usual culprit.
Test it with Http::fake(). Assert the outbound request rather than "it didn't throw":
Http::fake();
Notification::route('ntfy', 'topic')->notify(new DeploymentFinished('v1.2'));
Http::assertSent(fn ($r) => str_contains($r->url(), 'ntfy.sh/topic')
&& $r['priority'] === 3);
This pins the contract, so if you later self-host or change packages you'll catch regressions.
Auth modes differ. ntfy supports bearer token (Authorization: Bearer tk_...) and basic auth (user:pass). Pick one and keep it consistent with your self-hosted setup; mixing them across environments is a common config bug that only shows up when you leave local.
Trailing slashes on NTFY_SERVER. https://ntfy.example.com/ vs without — one will produce a double slash in the topic URL and a 404. Normalise it in a config accessor if you're self-hosting behind a proxy.
Vendor risk is low, but non-zero. This is a single-maintainer package wrapping a tiny HTTP API. The good news: the surface is small enough to fork or inline into a custom channel in an afternoon if it goes unmaintained. Prefer it for internal notifications, not customer-facing critical paths, until you've stress-tested it.
Extension point: swap the base URL per notification. If the package's config only exposes a global server, you can still achieve per-tenant routing (e.g. tenant A on their own ntfy server) by overriding the channel binding in a service provider and reading from the notifiable. That's the escape hatch to keep in your back pocket.
How can I help you explore Laravel packages today?