laravel-notification-channels/discord
Send Discord messages from your Laravel app through the notification system. This channel lets you post rich embeds, files, and webhook messages using Laravel's familiar notification classes.
Install and verify in one sitting.
composer require laravel-notification-channels/discord
There is no config file to publish, no migration, no service provider to register, and no bot token. Package auto-discovery wires the service provider. Pin "^1.8" for Laravel 13.x compatibility; the only runtime dependency beyond Laravel itself is the HTTP client you already have.
Read these three classes first. The package is small enough to read in full, and doing so settles most ambiguity before you write a line of application code:
NotificationChannels\Discord\DiscordChannel — the send() method shows exactly how the webhook is resolved from the notifiable and what happens on failure.NotificationChannels\Discord\DiscordMessage — the builder; its toArray() is the literal JSON body posted to Discord.NotificationChannels\Discord\DiscordEmbed — embed builder (title, description, color, fields, footer, thumbnail, image, author, timestamp).First use case to build: a deploy/failure alert into a single #ops channel. It exercises the whole surface — routing, payload building, queueing — in about 15 minutes and produces something you'll actually keep running.
// app/Notifications/DeployFailed.php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use NotificationChannels\Discord\DiscordChannel;
use NotificationChannels\Discord\DiscordMessage;
class DeployFailed extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public string $release, public string $reason) {}
public function via(object $notifiable): array
{
return [DiscordChannel::class];
}
public function toDiscord(object $notifiable): DiscordMessage
{
return DiscordMessage::create("Deploy of `{$this->release}` failed.");
}
}
// The notifiable supplies the webhook URL.
public function routeNotificationForDiscord(): ?string
{
return config('services.discord.ops_webhook');
}
// config/services.php
'discord' => [
'ops_webhook' => env('DISCORD_OPS_WEBHOOK'),
],
Then either Notification::route('discord', config('services.discord.ops_webhook'))->notify(new DeployFailed(...)) for a one-off, or $user->notify(...) when the webhook lives on the model.
If your first attempt 400s, dump $notification->toDiscord($notifiable)->toArray() and compare it against Discord's webhook payload docs — that array is the request body, byte for byte. There is nowhere else the problem can hide.
The channel asks the notifiable for its webhook via routeNotificationForDiscord(). That single seam gives you three clean routing strategies:
// 1. Per-tenant / per-team channel (column on the model, encrypted cast)
public function routeNotificationForDiscord(): ?string
{
return $this->discord_webhook_url;
}
// 2. Environment/config-driven (one channel per app or per environment)
public function routeNotificationForDiscord(): ?string
{
return config('services.discord.alerts_webhook');
}
// 3. Notifiable-agnostic: on-demand routing, no model at all
Notification::route('discord', $webhook)->notify(new IncidentOpened($incident));
Pattern 3 is the workhorse for system alerts — cron monitors, failed jobs, health checks — where there is no user to notify and inventing a fake notifiable would be noise.
Discord webhooks are rate-limited and occasionally slow; never send on the request path.
class DeployFailed extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 5;
public int $backoff = 10;
public string $queue = 'notifications-discord';
public function __construct(/* ... */)
{
$this->onQueue('notifications-discord');
}
}
Keeping Discord on its own queue means a burst of 429s cannot starve mail or other notifications. Combined with tries/backoff, a 429 surfaces as an exception the worker retries — which is correct, because a 429 is an operating condition, not a bug.
Plain content is fine for a one-liner; anything a human will scan at 3am should be an embed with a severity color.
public function toDiscord(object $notifiable): DiscordMessage
{
return DiscordMessage::create()
->embed(
DiscordEmbed::create()
->title("Deploy failed: {$this->release}")
->description($this->reason)
->color(match ($this->severity) {
'critical' => 0xE74C3C,
'warning' => 0xF1C40F,
default => 0x3498DB,
})
->field('Environment', $this->environment, true)
->field('Commit', substr($this->sha, 0, 7), true)
->field('Actor', $this->actor)
->footer('deployed via ' . config('app.name'))
->timestamp(now())
);
}
Colour is an integer, so keep a small severity→hex map in one place rather than scattering magic hex literals across notifications.
By default, text in content can ping @everyone, @here, or roles. For app-generated alerts that is a footgun — one unguarded variable and you page the entire server.
DiscordMessage::create('Build #4821 succeeded.')
->allowedMentions(['parse' => []]);
Then allow mentions deliberately, on the one or two notifications that genuinely need to reach a human.
Notification::fake() proves routing and stops there — it never serializes your payload, so a broken embed shape passes green.
// Layer 1 — routing
Notification::fake();
Notification::route('discord', 'https://discord.com/api/webhooks/x/y')->notify(new DeployFailed(...));
Notification::assertSentOnDemand(DeployFailed::class);
// Layer 2 — payload contract (this is the one that catches regressions)
$payload = (new DeployFailed($release, $reason))->toDiscord($notifiable)->toArray();
$this->assertSame("Deploy failed: {$release}", $payload['embeds'][0]['title']);
$this->assertLessThanOrEqual(2000, $payload['content'] ?? '');
$this->assertCount(3, $payload['embeds'][0]['fields']);
A third layer — Http::fake() around a single integration test — confirms the channel actually posts to the resolved URL. One test is enough; the routing and shaping are already covered.
Adding Discord alongside existing channels is just another via() entry:
public function via(object $notifiable): array
{
return array_filter([
'mail',
DiscordChannel::class => $this->shouldHitDiscord(),
]);
}
For "same message to three teams," loop over the webhook URLs with Notification::route('discord', $url) rather than trying to make one notification emit multiple payloads — the channel is a single-payload driver, and the loop is easier to reason about and to retry.
[prod] tag to every embed): a small base notification class with a final toDiscord() that calls an abstract buildMessage() and post-processes the result is cheaper than touching every notification as the app grows.Discord service the channel uses, or subclass DiscordChannel and swap it into via(). Both are viable precisely because the channel is thin.toArray() is the payload, confirm the exact key names it emits before hand-merging raw components into a message. Don't assume the builder covers everything the Discord API accepts.Anyone holding the URL can post into the channel. There is no second factor, no signature, no expiry. Treat it as a bearer secret, because that is what it is:
$casts = ['discord_webhook_url' => 'encrypted']) or in a secret manager — never in a plain text column.$hidden on any model that gets serialized to JSON.CouldNotSendNotification wrapping an HTTP exception can carry the request URI, and Laravel's HTTP client, Telescope, and Sentry will happily persist it. Configure before_send-style redaction; assume the URL will otherwise appear in a stack trace.Discord enforces per-webhook and global limits and responds 429 with a retry_after. The package surfaces this as a failed send (an exception from the channel), so:
tries and backoff.Discord validates sizes server-side, so oversized payloads blow up in production, not in tests. Limits worth internalising:
| Field | Limit |
|---|---|
content |
2000 chars |
Embed title |
256 chars |
Embed description |
4096 chars |
| Embed fields | 25 max; name 256 / value 1024 |
footer.text |
2048 chars |
| Embeds per message | 10 |
| Total across all embeds | 6000 chars |
Two habits: truncate exception messages and stack snippets before they reach a field (Str::limit($e->getMessage(), 1000)), and assert the limits in the payload contract test above. A 400 means the notification silently never arrived — the worst possible failure mode for an alerting channel.
A message with neither content nor embeds returns 400. If content is built from a nullable variable, guard it — DiscordMessage::create($reason ?? 'Unknown failure') is the cheap fix, and it forces you to decide what the fallback says.
New users expect config/discord.php. There isn't one. If routeNotificationForDiscord() returns null, the send fails at runtime, not at boot. Two mitigations:
string from the route method so tests fail fast, orDiscordChannel::class => $notifiable->discord_webhook_url !== null inside via().The second is the safer production pattern — a notifiable without a webhook silently skips Discord instead of throwing a job into the failed-jobs table.
If someone regenerates the webhook in Discord and forgets to update the secret, you get 404/401 on every send, forever, into the failed-jobs table where nobody looks. Add the Discord queue to your failed-job alerting, and check it during the webhook rotation drill.
routeNotificationFor('discord') vs routeNotificationForDiscord()Both spellings exist in Laravel's notification machinery, and it is easy to implement the one the channel doesn't call. This channel resolves via routeNotificationForDiscord() on the notifiable — implement that method exactly. Remember that on-demand routing (Notification::route('discord', $url)) bypasses the method entirely, which is convenient for tests and for alerts with no model behind them.
Community-maintained with a low release cadence. Pin the minor ("^1.8"), and on every bump smoke-test one real notification end-to-end — the package's own test suite won't catch Discord API drift for you. Release 1.8.0 is compatibility-only (Laravel 13.x, no API changes), which means you can take it ahead of the framework upgrade and avoid two constraint changes in the same window — but confirm the PHP floor implied by Laravel 13 against your platform before you do.
This is outbound, one-way, webhook-based. No replies, reactions, edits, threads, slash commands, or presence. If a stakeholder assumes "we post to Discord" implies an interactive bot, correct that early — the answer isn't "add a feature," it's "different package, different hosting, different permissions."
How can I help you explore Laravel packages today?