jeffersongoncalves / laravel-discord-logger
Send Laravel logs to Discord with deduplication, configurable error grouping, rate limiting, async delivery and a graceful no-op when the webhook URL is missing.
Package info
github.com/jeffersongoncalves/laravel-discord-logger
pkg:composer/jeffersongoncalves/laravel-discord-logger
Requires
- php: ^8.2|^8.3|^8.4
- laravel/framework: ^12.61.1|^13.12.0
- spatie/laravel-package-tools: ^1.14.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.24
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.7.4|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-master
- v2.2.0
- v2.1.0
- v2.0.3
- v2.0.2
- v2.0.1
- v2.0.0
- v1.1.1
- v1.1.0
- v1.0.0
- dev-feat/livewire-and-custom-context
- dev-fix/connection-failures-and-webhook-token
- dev-feat/test-command-exception
- dev-feat/stacktrace-attachment
- dev-feat/runtime-context
- dev-fix/smart-stacktrace-windows-vendor
- dev-fix/unicode-context-json
This package is auto-updated.
Last update: 2026-09-30 13:42:41 UTC
README
Laravel Discord Logger
Send Laravel logs to Discord — built for production. A Monolog channel with deduplication, configurable error grouping, rate limiting, async delivery with 429 backoff, and a graceful no-op when the webhook URL is missing.
Installation
composer require jeffersongoncalves/laravel-discord-logger
Publish the config (optional):
php artisan vendor:publish --tag="laravel-discord-logger-config"
Configuration
Add a channel to config/logging.php:
'discord' => [ 'driver' => 'custom', 'via' => \JeffersonGoncalves\DiscordLogger\Logger::class, 'level' => env('LOG_DISCORD_LEVEL', 'error'), 'url' => env('LOG_DISCORD_WEBHOOK_URL'), ],
Stack it onto your default channel so errors fan out:
'stack' => [ 'driver' => 'stack', 'channels' => ['single', 'discord'], 'ignore_exceptions' => false, ],
Set the webhook (leave empty in local/testing — nothing will be sent, nothing will break):
LOG_DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy
How the improvements work
Error grouping (fingerprint)
config/discord-logger.php → grouping.strategy:
message— group by message textlevel_message— group by level + messageexception(default) — group by exception class + file + line- a
callbackfn (\Monolog\LogRecord $record): stringfor full control
With grouping.normalize on, volatile tokens (numbers, UUIDs, hashes) are stripped before hashing, so User 123 not found and User 456 not found collapse together.
Deduplication
Within deduplication.window seconds, only the first occurrence of a fingerprint is sent. Repeats are counted silently. When the window closes, a single summary (🔁 occurred N×) is delivered — turn it off with deduplication.summary => false.
Rate limiting
rate_limit.global— hard cap on total messages app-widerate_limit.per_fingerprint— extra guard against a single looping error
Async delivery
Delivery runs through a queued job by default (queue.enabled). Discord 429s are retried respecting Retry-After; an unreachable Discord (DNS/connect/timeout) or a 5xx is retried with backoff (10s, 30s, 60s, 120s); bad webhooks (4xx) fail fast instead of looping. After the last attempt the job gives up quietly — the delivery job never throws, so a Discord outage can't produce Discord messages about itself. Tune timeout / connect_timeout (seconds). Set queue.enabled => false to send inline (best-effort, errors swallowed).
This means a queue worker must be running (
php artisan queue:work, Horizon, or supervisor) forLog::debug/info/error(...)calls to actually reach Discord.discord-logger:testsends inline and bypasses the queue entirely, so it will succeed even with no worker running — don't use it alone to confirm real logging works. If you don't run a worker (or don't want to), setDISCORD_LOGGER_QUEUE=false.
State store
Dedup + rate-limit counters live in the cache store named by store (null = default). Use Redis in production for atomic counters.
Per-level webhooks & mentions
Route a level to its own channel and ping someone when it matters:
'webhooks' => [ 'EMERGENCY' => env('DISCORD_LOGGER_WEBHOOK_ALERTS'), 'CRITICAL' => env('DISCORD_LOGGER_WEBHOOK_ALERTS'), ], 'mentions' => [ 'EMERGENCY' => '@here', 'CRITICAL' => '<@&123456789012345678>', // role id ],
allowed_mentions is set automatically, so @here / @everyone / role / user pings actually fire.
Runtime context
Each message says where it came from, as extra embed fields (turn off with DISCORD_LOGGER_RUNTIME_CONTEXT=false):
- Request — method, URL, route name, user id (only if already resolved — logging never triggers a user lookup) and IP
- Livewire — on a Livewire update request (which always hits
/livewire/update), the component name(s) it targeted and the page path, read from the component snapshots - Job — class, queue, connection, attempt and id of the queued job being processed, including when the log is the job's own failure
- Command — the artisan command name (never its arguments, which may carry secrets), for console logs outside a job
To add your own fields — the tenant, the user kept in the session... — point context_resolver in config/discord-logger.php to an invokable class. It runs at log time; each key becomes an embed field (a scalar, or an array shown as key: value lines), redacted like the rest. It works with runtime_context off too, and a resolver that throws only costs its own fields:
// config/discord-logger.php 'context_resolver' => App\Logging\DiscordContext::class, // app/Logging/DiscordContext.php class DiscordContext { public function __invoke(): array { if (! session('logged_in')) { return ['User' => 'Guest']; } return [ 'Company' => session('company.name'), 'User' => ['id' => session('user_id'), 'name' => session('user_name')], ]; } }
Anything in the record's extra — e.g. data added with Laravel's Context::add(), or Monolog processors — is shown in an Extra field. All of it goes through redaction, so ?token=... in a URL is masked.
Full stacktrace attachment
The embed shows at most 1000 characters of stacktrace (vendor frames dropped in smart mode). When the exception is longer, the whole thing — message, every frame and each chained previous exception, redacted — is also sent as a stacktrace.txt attachment, so nothing is lost to truncation. Disable with DISCORD_LOGGER_ATTACH_STACKTRACE=false; it's also skipped when stacktrace is none. The file is capped at 64 KiB to keep the queued job under SQS's 256 KiB limit.
Context redaction
Sensitive data is masked before it ever reaches Discord, via two complementary strategies:
- Key matching (
redact) — case-insensitive key fragments whose values are masked. Defaults coverpassword,secret,token,authorization,api_key,apikey. Scrubbing recurses into nested arrays and the public properties of objects carried in the context. - Value patterns (
redact_value_patterns) — PCRE regexes matched against scalar values, so a secret leaking under an innocuous key (e.g.url,auth) — or inside the message text and the stacktrace — is still caught. Defaults coverBearertokens, JWTs,Basiccredentials, sensitive"key":"value"pairs inside serialized JSON text, and?token=...-style query strings; leave the array empty to disable value-based redaction. A pattern may declare a named(?<safe>...)group to keep that part of the match in the output (e.g. the key name), so masking stays readable instead of swallowing the surrounding text — any other capture group is ignored, so it can't accidentally leak part of the secret back out.
Fallback channel & safety
The handler never throws and is guarded against logging-while-logging recursion. If delivery fails (inline, or after the queued job's last retry), the error is swallowed; set fallback_channel (e.g. 'single') to record those failures instead of losing them. The fallback message is redacted, and a fallback channel that reaches Discord (the Discord channel itself, or a stack containing it) is ignored so a failure can't loop.
The Discord webhook token (/api/webhooks/{id}/{token}) is always masked by the redactor — in fallback messages, discord-logger:test output and anything sent to Discord — independently of redact_value_patterns, so it's covered even with an older published config.
Troubleshooting
discord-logger:test works, but Log::debug/info/error(...) never shows up in Discord.
The test command sends inline, straight to the webhook. Real log calls go through a queued job by default (see Async delivery), so nothing is delivered until a queue worker processes it. Fixes:
- Run a worker:
php artisan queue:work(or Horizon/supervisor in production), or - Set
DISCORD_LOGGER_QUEUE=falseto send inline instead (no worker needed).
This is especially easy to hit on a fresh Laravel install (Laravel 11+, including 13): QUEUE_CONNECTION defaults to database, but no worker runs automatically — jobs just sit pending in the jobs table.
Also check the channel's level in config/logging.php — if it's set above the level you're logging at (e.g. level => 'error' while calling Log::debug(...)), Monolog filters the record before it ever reaches the handler.
Commands
# Publish the config + optionally star the repo php artisan discord-logger:install # Send a test message to verify the webhook php artisan discord-logger:test --channel=discord # Send a sample exception through the real converter (embed + stacktrace.txt attachment) php artisan discord-logger:test --exception
Testing
composer test
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). See License File.
