Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Discord Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. NotificationChannels\Discord\DiscordChannel — the send() method shows exactly how the webhook is resolved from the notifiable and what happens on failure.
  2. NotificationChannels\Discord\DiscordMessage — the builder; its toArray() is the literal JSON body posted to Discord.
  3. 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.


Implementation Patterns

Route the webhook from the notifiable, not from the package

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.

Queue it, and isolate the queue

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.

Embeds for anything structured

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.

Mention discipline

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.

Testing: two layers, not one

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.

Fan-out and multi-channel

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.

Where to extend

  • Global payload decoration (e.g. appending a [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.
  • Custom transport/retry: bind your own implementation of the Discord service the channel uses, or subclass DiscordChannel and swap it into via(). Both are viable precisely because the channel is thin.
  • Interactive components (buttons, select menus) and file attachments are supported by the message builder; because 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.

Gotchas and Tips

The webhook URL is the credential

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:

  • Store per-tenant webhooks with an encrypted cast ($casts = ['discord_webhook_url' => 'encrypted']) or in a secret manager — never in a plain text column.
  • Add the column to $hidden on any model that gets serialized to JSON.
  • Scrub it from error trackers. A 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.
  • Write the rotation runbook before you need it: regenerate the webhook in Discord, update the secret, delete the old one.

Rate limits are normal operation

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:

  • Notifications must be queued with tries and backoff.
  • Deduplicate before you enqueue. A monitor that fires on every failed health check will burn the limit and then fail all your alerts — throttle or collapse at the source, not in the queue.
  • Do not put on-call paging on this path without validating behaviour under load. For business-critical alerting this is a channel to mirror into, not the sole delivery mechanism.

Payload limits fail as runtime 400s

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.

Empty messages are rejected

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.

No config file means no global default

New users expect config/discord.php. There isn't one. If routeNotificationForDiscord() returns null, the send fails at runtime, not at boot. Two mitigations:

  • Return a non-nullable string from the route method so tests fail fast, or
  • Gate the channel: DiscordChannel::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.

A deleted webhook looks like an outage

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.

Version pinning and upgrade cadence

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.

Scope boundary worth writing down

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."

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource