nabafzar / laravel-log-center
Unified logging, multi-channel notifications, and built-in log viewer for Laravel.
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^7.5|^8.0
- laravel/framework: ^10.0|^11.0|^12.0|^13.0
- monolog/monolog: ^3.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.5|^11.0|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-14 18:46:57 UTC
README
Laravel Log Center
Route every Laravel log event to Telegram, Bale, Mattermost, or any HTTP endpoint โ with secret masking, async queuing, fallback transport, alert suppression, payload enrichment, a built-in log viewer, and an HTTP ingress for external services.
// One channel. Errors land in Telegram, secrets masked, off the request path. Log::error('Payment gateway timeout', ['order_id' => 'ORD-999']);
Features
- ๐ Multiple transports โ Telegram (with forum topics), Bale, Mattermost, generic webhook, and a null sink.
- ๐ญ Per-transport formatters โ plain, JSON, Markdown, Telegram HTML, Bale Markdown.
- ๐ Secret masking โ keys and in-message patterns are masked before formatting, queuing, or sending.
- โก Async dispatch โ push delivery onto a queue so logging never blocks a request.
- ๐ Fallback transport โ automatically retry through a second driver when the primary fails.
- ๐ Alert suppression โ collapse duplicate errors within a TTL window to stop notification floods.
- ๐ Daily / weekly digest โ a summary of entry counts and the top issues, sent through your driver.
- ๐งฉ Payload enrichment โ attach git SHA, hostname, or any context to every log.
- ๐งญ Attribute routing โ
#[LogTopic]routes a controller's logs to a specific Telegram thread. - ๐ Built-in log viewer โ read-only viewer with search, live tail, and downloads.
- ๐ฅ Incoming ingress โ accept structured logs from other apps over HTTP.
Why Log Center?
Built on Monolog, not instead of it
Log Center does not replace Laravel's logging โ it plugs into it. The integration point is a
standard Monolog handler (LogCenterHandler) that you register
on a normal logging channel, exactly like any other handler:
'logcenter' => [ 'driver' => 'monolog', 'handler' => \Nabafzar\LogCenter\Logging\LogCenterHandler::class, ],
So every Log::error(), every Monolog processor, every channel and stack you already use keeps
working unchanged. Monolog stays the logging engine; Log Center is what happens after a record
is emitted โ turning that single record into masked, formatted, routed, deduplicated delivery
across one or more providers.
Why not just use Monolog's own transport handlers?
Monolog ships a TelegramBotHandler, and there are community handlers for other services. They
are perfectly fine when all you need is "write this record to one chat." The reason this package
exists is that a raw handler is a sink โ it takes a LogRecord and writes it to one
destination โ whereas real alerting needs a pipeline. Stack several Monolog handlers together
and you end up re-implementing masking, queuing, fallback, and deduplication in each one, with no
shared contract between providers. Log Center makes that pipeline the product:
| Concern | Raw Monolog handler | Log Center |
|---|---|---|
| Context | Whatever is in the record | Lifts exception, request, and authenticated user into a typed LogPayload |
| Secrets | Sent as-is | Masked by key and message pattern before formatting/queue/send |
| Formatting | Baked into the handler | Swappable LogFormatterInterface per transport |
| Delivery cost | On the request thread | Optional queue dispatch with retry + backoff |
| Resilience | Fails silently | Fallback driver, LogDispatchFailed event, recursion-safe error handling |
| Noise | Every duplicate sent | Fingerprint-based suppression with occurrence counts |
| Routing | One channel | #[LogTopic] โ Telegram forum thread / #hashtag on other drivers |
| Iranian platforms | Not covered | First-class Bale support |
Rule of thumb: if you need "ship this record to one chat," a Monolog handler is enough. If you need masking, async delivery, fallback, deduplication, and routing as one contract across several providers, that is what this package adds on top of Monolog.
Table of Contents
- Requirements
- Installation
- Quick Start
- How It Works
- Drivers
- Formatters
- Queue Dispatch
- Fallback Transport
- Failure Channel
- Alert Suppression
- Log Digest
- Secret Masking
- Payload Enrichment
- Events
- Log Topic Attribute
- Log Viewer
- Incoming Log Ingress
- Custom Transports
- Custom Formatters
- Testing Your App
- Testing
Requirements
| Requirement | Supported |
|---|---|
| PHP | 8.2, 8.3, 8.4 (Laravel 13 needs 8.3+) |
| Laravel | 10, 11, 12, 13 |
| Monolog | 3.x |
Installation
composer require nabafzar/laravel-log-center
The service provider is auto-discovered. Publish the configuration file:
php artisan vendor:publish --tag=logcenter-config
Quick Start
Add a channel to config/logging.php:
'channels' => [ 'logcenter' => [ 'driver' => 'monolog', 'handler' => \Nabafzar\LogCenter\Logging\LogCenterHandler::class, 'with' => ['level' => env('LOG_LEVEL', 'error')], ], ],
A record is sent only when it meets both the channel level and LOGCENTER_LEVEL (default error),
so a channel left at Laravel's default debug level still sends errors and above only. Set
LOGCENTER_LEVEL=debug to let the channel level decide on its own.
Set it as your log channel and configure a transport driver:
LOG_CHANNEL=logcenter LOGCENTER_DRIVER=telegram LOGCENTER_TELEGRAM_TOKEN=your-bot-token LOGCENTER_TELEGRAM_CHAT_ID=-100xxxxxxxxxx
Or stack it alongside an existing channel so file logging keeps working:
LOG_CHANNEL=stack LOG_STACK=daily,logcenter
Every Log::error(), Log::critical(), or uncaught exception now lands in your configured
transport. Verify your setup without triggering a real error:
php artisan logcenter:check # checks the setup without sending anything php artisan logcenter:test-log # sends a real test message
logcenter:check lists each part of the setup as OK, WARN, FAIL or SKIP and exits with code
1 when something is broken, so it also fits a deploy pipeline. It checks that Log Center is attached
to a log channel, the level, the default and fallback drivers and their formatters, and the
credentials: Telegram and Bale tokens with getMe and chat access with getChat, a Mattermost bot
token with /users/me โ read-only calls that never post a message. Webhook URLs are only validated,
because checking one would mean posting to it. It also checks the queue connection, the suppression
cache, the failure channel, the viewer gate and the incoming token.
php artisan logcenter:check --driver=bale # check one driver php artisan logcenter:check --offline # skip calls to the providers
How It Works
Monolog LogRecord
โ
โผ
LogCenterHandler::write()
โ โข resolve #[LogTopic] (call stack โ current route)
โ โข build a typed LogPayload (exception, request, user lifted from context)
โผ
LogDispatcher::dispatch()
โ โข enrich payload โข mask secrets โข check suppression fingerprint
โ โข (optional) push DispatchLogJob onto a queue
โผ
TransportManager โโ resolves the active Formatter + Transport
โ
โผ
Formatter โ FormattedMessage โ Transport.send()
โ
โโ on failure โ fallback driver โ LogDispatchFailed event
Masking runs before formatting and before the payload is serialized onto a queue, so unmasked secrets never reach queue storage, transport metadata, or error logs.
Drivers
Telegram
Delivers messages via the Telegram Bot API. Supports forum topics, message splitting, and per-level routing.
LOGCENTER_DRIVER=telegram LOGCENTER_TELEGRAM_TOKEN=your-bot-token LOGCENTER_TELEGRAM_CHAT_ID=-100xxxxxxxxxx LOGCENTER_TELEGRAM_PARSE_MODE=HTML # HTML or MarkdownV2 LOGCENTER_TELEGRAM_TIMEOUT=5
Per-level forum topics โ route different log levels to different threads:
// config/logcenter.php 'drivers' => [ 'telegram' => [ 'topics_level' => [ 'critical' => 1001, 'error' => 1002, 'warning' => 1003, ], ], ],
Tag-to-topic routing โ map #[LogTopic] tag names to thread IDs (takes priority over topics_level):
'drivers' => [ 'telegram' => [ 'tag_topics' => [ 'payments' => 1010, 'checkout' => 1011, ], ], ],
Message splitting โ split long messages instead of truncating:
LOGCENTER_TELEGRAM_SPLIT_LONG_MESSAGES=true LOGCENTER_TELEGRAM_DELAY_BETWEEN_MESSAGES=true
Bale
Delivers via the Bale messenger Bot API โ the Iranian messaging platform.
LOGCENTER_DRIVER=bale LOGCENTER_BALE_TOKEN=your-bot-token LOGCENTER_BALE_CHAT_ID=your-chat-id
Mattermost
Supports two modes: incoming webhook and bot API.
# Incoming webhook LOGCENTER_DRIVER=mattermost LOGCENTER_MATTERMOST_WEBHOOK=https://your-mattermost.com/hooks/abc123 # Bot API LOGCENTER_MATTERMOST_BASE_URL=https://your-mattermost.com LOGCENTER_MATTERMOST_BOT_TOKEN=your-bot-token LOGCENTER_MATTERMOST_CHANNEL_ID=channel_id_here
Webhook
Sends a structured payload to any HTTP endpoint.
LOGCENTER_DRIVER=webhook LOGCENTER_WEBHOOK_URL=https://your-endpoint.example.com/logs LOGCENTER_WEBHOOK_METHOD=POST
Custom headers:
'drivers' => [ 'webhook' => [ 'headers' => ['Authorization' => 'Bearer your-token'], ], ],
Null
Discards all messages silently. Useful as the default driver in local or testing environments.
LOGCENTER_DRIVER=null
Formatters
| Name | Best for | Description |
|---|---|---|
plain |
Webhook, generic | Plain text |
markdown |
Mattermost, generic | GitHub-flavoured Markdown |
json |
Log aggregators | Structured JSON object |
telegram_html |
Telegram | HTML with Telegram entity support |
bale_markdown |
Bale | Markdown formatted for Bale |
Each driver defaults to a sensible formatter (Telegram โ telegram_html, Bale โ bale_markdown,
Mattermost โ markdown). Override globally or per driver:
LOGCENTER_FORMATTER=telegram_html # global default LOGCENTER_TELEGRAM_FORMATTER=telegram_html # per driver
Queue Dispatch
Dispatch log jobs asynchronously so transport delivery never blocks a request:
LOGCENTER_QUEUE_ENABLED=true LOGCENTER_QUEUE_CONNECTION=redis LOGCENTER_QUEUE_NAME=logs LOGCENTER_QUEUE_TRIES=3
Failed jobs are retried with exponential backoff: 10 s โ 60 s โ 300 s. Permanent failures
fire a LogDispatchFailed event.
Masking runs before the job is pushed to the queue โ sensitive data never enters queue storage.
Use the queue in production. Without it, logs are sent during the request that wrote them. That costs nothing while the destination is healthy, but if it accepts the connection and then stops answering, every request that logs an error waits for the driver timeout (5 s by default) before the user gets a response โ plus the same again when a fallback driver is configured. If you send synchronously, keep the timeouts short (
LOGCENTER_TELEGRAM_TIMEOUT,LOGCENTER_BALE_TIMEOUT,LOGCENTER_MATTERMOST_TIMEOUT,LOGCENTER_WEBHOOK_TIMEOUT).
Fallback Transport
When the primary driver fails, a fallback driver is tried automatically:
LOGCENTER_DRIVER=telegram LOGCENTER_FALLBACK_DRIVER=webhook
The fallback must differ from the primary driver. Any failed send triggers it โ a timeout, HTTP 429 or 5xx, and also a configuration error such as a wrong token (4xx) โ so the alert still gets out.
Failure Channel
Log Center never interrupts your app: if it cannot deliver a log, or something fails inside the package, the request carries on. To keep a record of those problems in your own log files, name a Laravel log channel:
LOGCENTER_FAILURE_CHANNEL=single # or daily, or any channel from config/logging.php
It then writes a warning there for every log that could not be delivered after the fallback and all queue retries, and for errors inside the package:
local.WARNING: Log Center could not deliver a log {"driver":"telegram","error":"HTTP request returned status code 401","level":"ERROR","message":"Order 42 failed",...}
Secrets in the error text (bot tokens, webhook URLs) are already masked. Without the setting, the same
messages go to PHP's error_log. The original log itself is written by your other channels as usual,
for example single in LOG_STACK=single,logcenter.
Alert Suppression
Prevent notification floods by suppressing repeated identical logs within a time window:
LOGCENTER_SUPPRESSION_ENABLED=true LOGCENTER_SUPPRESSION_TTL=300 # seconds (default: 5 minutes) LOGCENTER_SUPPRESSION_STORE=redis # null = app's default cache store
The first occurrence is delivered normally. Duplicates within the TTL window fire a
LogSuppressed event with an $occurrences counter instead of re-sending.
Production note: suppression and its occurrence counter are backed by the configured cache store. In a multi-worker deployment, point
LOGCENTER_SUPPRESSION_STOREat a shared, atomic store such as Redis. With per-process stores (e.g.array), each worker tracks its own counts and suppression will not be global.
Log Digest
A summary of the last day or week, sent through your driver like any other log: entry counts per level and the most
frequent issues, with new ones marked [NEW].
Daily log digest ยท Oct 10, 09:00 โ Oct 11, 09:00
152 entries ยท 12 issues ยท 3 new
CRITICAL 2 ยท ERROR 120 ยท WARNING 30
Top issues:
1. [NEW] ERROR ร45 โ RuntimeException: Order total is negative (app/Http/Controllers/OrderController.php:42)
2. ERROR ร31 โ QueryException: Deadlock found when trying to get lock
...
โฆ and 2 more issues.
Log viewer: https://app.test/log-viewer
Run it by hand, or let Log Center add it to Laravel's scheduler:
php artisan logcenter:digest # last 24 hours php artisan logcenter:digest --period=weekly # last 7 days php artisan logcenter:digest --since="48 hours ago" --level=error php artisan logcenter:digest --driver=mattermost # another driver for this run php artisan logcenter:digest --dry-run # print it, send nothing
LOGCENTER_DIGEST_ENABLED=true # schedule it (your cron must run `php artisan schedule:run`) LOGCENTER_DIGEST_FREQUENCY=daily # daily or weekly LOGCENTER_DIGEST_TIME=09:00 LOGCENTER_DIGEST_DAY=1 # weekly only: 0 = Sunday โฆ 6 = Saturday LOGCENTER_DIGEST_TIMEZONE= # default: app.timezone LOGCENTER_DIGEST_DRIVER= # default: LOGCENTER_DRIVER LOGCENTER_DIGEST_LEVEL=warning # lowest level counted LOGCENTER_DIGEST_TOP=10 # issues listed LOGCENTER_DIGEST_SKIP_EMPTY=true # send nothing for a quiet period
How it works:
- The counts come from the log files in
viewer.paths(the viewer does not have to be enabled), grouped exactly like the viewer groups issues. Write your logs to a file channel (singleordaily) alongsidelogcenter. - An issue is
[NEW]when it does not appear before the period in the files still kept. - The digest is sent as an
infolog on thedigestchannel with the tagdigest, so masking, the fallback driver andLogCenter::fake()apply. On Telegram, map the tag to a forum topic with'tag_topics' => ['digest' => 1234]. - Files up to
LOGCENTER_DIGEST_MAX_FILE_SIZE(100 MB by default,0for no limit) are read, even when they are larger than the viewer's own limit. - On several servers, schedule the command yourself with
->onOneServer()instead ofLOGCENTER_DIGEST_ENABLED, so only one digest is sent.
Secret Masking
Sensitive values are masked before formatting, queue dispatch, and sending โ including nested arrays and message string patterns.
Default masked keys: password, password_confirmation, token, access_token,
refresh_token, authorization, api_key, secret
A key is masked when its name contains one of these (case-insensitive by default), so password
also covers current_password and new_password, and token covers api_token and Laravel's
CSRF _token.
'mask' => [ 'keys' => ['password', 'token', 'stripe_key'], 'replacement' => '******', 'case_sensitive' => false, ],
Payload Enrichment
Attach custom context to every log payload before masking and dispatch:
use Nabafzar\LogCenter\Contracts\PayloadEnricherInterface; use Nabafzar\LogCenter\DTO\LogPayload; use Nabafzar\LogCenter\Processors\PayloadProcessor; // In AppServiceProvider::boot() $this->app->make(PayloadProcessor::class) ->addEnricher(new class implements PayloadEnricherInterface { public function enrich(LogPayload $payload): LogPayload { $data = (array) $payload; $data['extra'] = array_merge($payload->extra, [ 'git_sha' => config('app.git_sha'), 'server_name' => gethostname(), ]); return new LogPayload(...$data); } });
Enrichers run before secret masking, so any sensitive values added here are still masked.
Events
| Event | Fired when |
|---|---|
LogDispatched |
A log was delivered successfully |
LogDispatchFailed |
A delivery attempt failed (primary or fallback) |
LogSuppressed |
A duplicate log was suppressed within the TTL window |
use Nabafzar\LogCenter\Events\LogDispatched; use Nabafzar\LogCenter\Events\LogDispatchFailed; use Nabafzar\LogCenter\Events\LogSuppressed; Event::listen(LogDispatched::class, function (LogDispatched $event): void { // $event->payload โ the LogPayload that was sent // $event->result โ the TransportResult }); Event::listen(LogSuppressed::class, function (LogSuppressed $event): void { // $event->occurrences โ suppression count for this fingerprint }); Event::listen(LogDispatchFailed::class, function (LogDispatchFailed $event): void { // $event->retryable โ whether the queue will retry // $event->result->errorMessage });
Log Topic Attribute
Route logs to a specific Telegram topic thread and append a hashtag on every other driver by
decorating a method or class with #[LogTopic].
The resolver checks the active call stack and the current Laravel route action, so controller attributes still apply when an exception is logged after the action has unwound.
use Nabafzar\LogCenter\Attributes\LogTopic; #[LogTopic(tag: 'payments', topicId: 1234)] public function charge(ChargeRequest $request): JsonResponse { // Telegram โ routed to forum topic 1234 // All others โ "#payments" appended to message }
Multiple tags, class-level, or tag-only (no topic ID) are all supported. Topic resolution priority:
topicIdfrom the#[LogTopic]attributetag_topicsconfig (tag โ topic ID)topics_levelconfig (level โ topic ID)drivers.telegram.topic_idconfig default
Octane & queue workers: attribute resolution walks the live call stack and falls back to the current Laravel route action. In a queued job or long-running Octane worker where the decorated controller frame is gone and no route is bound, the
#[LogTopic]attribute will not resolve. Two alternatives:Option A โ decorate the job's
handle()method:#[LogTopic(tag: 'payments', topicId: 1234)] public function handle(): void { ... }Option B โ pass routing explicitly in the log context:
Log::error('Payment job failed', [ 'logcenter_topic_id' => 1234, 'logcenter_tags' => ['payments'], 'order_id' => 'ORD-999', ]);The attribute always takes priority. Context values are the fallback when no attribute is on the stack. Both
logcenter_topic_idandlogcenter_tagsare consumed and do not appear in the log payload's context.
Log Viewer
A read-only viewer for your Laravel log files, built to answer "what broke, where, and how often?" quickly.
LOGCENTER_VIEWER_ENABLED=true LOGCENTER_VIEWER_ROUTE_PREFIX=log-viewer # โ /log-viewer LOGCENTER_VIEWER_AUTH_ENABLED=true # default: true LOGCENTER_VIEWER_CODE_SNIPPETS=true # default: follows APP_DEBUG LOGCENTER_VIEWER_EDITOR=vscode # vscode, cursor, phpstorm, sublime, or a URL template LOGCENTER_VIEWER_CACHE=true # default: true
Large logs stay fast: parsed entries are kept between requests in storage/framework/cache/logcenter,
and when a log grows only the new lines are read. A rotated or truncated file is parsed again from
the start. On a 4 MB log with 4,000 entries, a page load drops from about 190 ms to about 45 ms.
What you get:
- Issues, not lines โ identical errors are grouped, with event count, first seen and last seen.
- Exception details โ class, message and
file:lineparsed from Laravel's log format, plus a preview of the source lines around the error (PHP files inside your app only, and only whencode_snippetsis on). - Readable stack traces โ your application frames are highlighted and vendor frames are folded. "Caused by" chains for previous exceptions are shown too.
- Context, occurrences and raw tabs โ highlighted JSON context, every occurrence with its raw entry, and the latest raw entry with a copy button.
- Copy for AI โ "Copy stack trace" copies the raw trace, and "Copy AI prompt" copies a ready-to-paste
prompt for ChatGPT, Claude or any other assistant: the exception, location, occurrence counts, source
lines, "Caused by" chain, stack trace and context. Secrets are masked with your
maskrules and your app's base path is removed from file paths. Nothing is sent anywhere; the text only goes to your clipboard. - Activity chart โ errors, warnings and other entries over time, following your filters.
- Search and level filters โ level chips keep their counts while you filter.
- Notification links โ the "open in viewer" link in Telegram, Bale and Mattermost messages opens the exact error, already expanded.
- Live tail, download, dark mode, mobile layout and keyboard shortcuts (
/search,j/kmove,eexpand all,llive,ttheme,?help).
LOGCENTER_VIEWER_EDITOR turns file paths into "open in editor" links. A custom template can use
{file} and {line}, for example idea://open?file={file}&line={line}.
Who can open the viewer
The viewer exposes raw log contents, including stack traces and request data, so being logged in is
not enough. Access is decided by the viewLogCenter gate. Until you define it, the viewer is only
available in the local environment and returns 403 everywhere else.
Define the gate in a service provider's boot() method, for example AppServiceProvider:
use Illuminate\Support\Facades\Gate; Gate::define('viewLogCenter', function ($user) { return in_array($user->email, [ 'admin@example.com', ]); });
The gate runs after the auth middleware (enabled by default with LOGCENTER_VIEWER_AUTH_ENABLED),
so guests are redirected to log in first. If your app has no login route (an API-only app, for
example), guests get a 401 instead of an error. Inside a directory listed in viewer.paths, only *.log
files can be opened, downloaded or tailed.
Live Tail โ click the Live button to stream new entries in real time via SSE (falls back to polling automatically).
For non-browser consumers:
GET /log-viewer/tail/poll?file=/path/to/laravel.log&offset=12345
Incoming Log Ingress
Accept structured log payloads from external applications and dispatch them through the configured transport:
LOGCENTER_INCOMING_ENABLED=true LOGCENTER_INCOMING_TOKEN=your-secret-token LOGCENTER_INCOMING_ROUTE_PREFIX=incoming-logs
POST /incoming-logs HTTP/1.1 X-LogCenter-Token: your-secret-token Content-Type: application/json { "level": "error", "message": "Payment gateway timeout", "channel": "payments", "context": { "order_id": "ORD-999" } }
Rate-limited to 60 requests per minute by default. Override via config/logcenter.php:
'incoming' => [ 'middleware' => ['api', 'throttle:120,1'], ],
Custom Transports
1. Create the transport class โ suggested path: app/Logging/DiscordTransport.php
<?php namespace App\Logging; use Illuminate\Support\Facades\Http; use Nabafzar\LogCenter\Contracts\LogTransportInterface; use Nabafzar\LogCenter\DTO\FormattedMessage; use Nabafzar\LogCenter\DTO\LogPayload; use Nabafzar\LogCenter\DTO\TransportResult; use Nabafzar\LogCenter\Enums\MessageFormatEnum; final class DiscordTransport implements LogTransportInterface { public function __construct( private readonly string $webhookUrl, private readonly int $timeout, ) {} public function send(LogPayload $payload, FormattedMessage $message): TransportResult { $response = Http::timeout($this->timeout) ->post($this->webhookUrl, ['content' => $message->content]); return $response->successful() ? TransportResult::success('discord', statusCode: $response->status()) : TransportResult::failed('discord', errorMessage: $response->body(), statusCode: $response->status()); } public function supportedFormats(): array { return [MessageFormatEnum::PLAIN, MessageFormatEnum::MARKDOWN]; } public function supportsTopics(): bool { // Return true only if your transport can route messages to // sub-channels / threads based on #[LogTopic] tag names. // Either way, the built-in formatters render tags as "#hashtags" in the message body. return false; } }
2. Register the driver in AppServiceProvider::boot():
use App\Logging\DiscordTransport; use Nabafzar\LogCenter\Factories\TransportFactory; $this->app->resolving(TransportFactory::class, function (TransportFactory $factory, $app): void { $factory->extend('discord', fn () => new DiscordTransport( webhookUrl: config('services.discord.webhook_url'), timeout: config('services.discord.timeout', 5), )); });
3. Add config to config/services.php:
'discord' => [ 'webhook_url' => env('DISCORD_WEBHOOK_URL'), 'timeout' => env('DISCORD_TIMEOUT', 5), ],
4. Activate it:
LOGCENTER_DRIVER=discord DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
supportsTopics() reference
| Return value | Behaviour when #[LogTopic] is present |
|---|---|
false (default) |
Tag appended as #payments in the message body |
true |
Transport receives $payload->tags and is responsible for routing to the correct sub-channel/thread |
Built-in transports: TelegramTransport returns true (routes to forum topic threads); all
others return false.
Custom Formatters
A formatter turns a log into the text a transport sends. Write your own when the built-in ones do not fit, for example to send JSON in the shape your webhook expects.
1. Create the formatter by implementing LogFormatterInterface:
namespace App\Logging; use Nabafzar\LogCenter\Contracts\LogFormatterInterface; use Nabafzar\LogCenter\DTO\FormattedMessage; use Nabafzar\LogCenter\DTO\LogPayload; final class OpsgenieFormatter implements LogFormatterInterface { public function format(LogPayload $payload): FormattedMessage { return new FormattedMessage( content: json_encode([ 'message' => mb_substr($payload->message, 0, 130), 'priority' => $payload->level === 'CRITICAL' ? 'P1' : 'P3', 'details' => $payload->context, ]), format: 'json', ); } }
The payload is already masked when it reaches the formatter, so secrets stay hidden.
2. Register it in AppServiceProvider::boot():
use App\Logging\OpsgenieFormatter; use Nabafzar\LogCenter\Factories\FormatterFactory; $this->app->make(FormatterFactory::class) ->extend('opsgenie', fn ($app) => new OpsgenieFormatter);
The resolver receives the container. Registering a built-in name (plain, json, markdown,
telegram_html, bale_markdown) replaces that formatter. Names are case-insensitive.
3. Use it globally or for one driver:
LOGCENTER_FORMATTER=opsgenie # every driver without its own formatter LOGCENTER_WEBHOOK_PAYLOAD=text # send the formatted text as the request body
Or per driver in config/logcenter.php: 'drivers' => ['webhook' => ['formatter' => 'opsgenie', ...]].
Testing Your App
LogCenter::fake() records what would be sent instead of sending it, so you can test your own
alerts. The rest of the pipeline still runs โ level filter, masking, formatter, #[LogTopic] routing,
suppression and fallback โ and delivery becomes synchronous, so it works with the queue enabled.
use Nabafzar\LogCenter\DTO\FormattedMessage; use Nabafzar\LogCenter\DTO\LogPayload; use Nabafzar\LogCenter\Facades\LogCenter; public function test_a_failed_payment_alerts_the_team(): void { $fake = LogCenter::fake(); $this->postJson('/checkout', ['card' => 'declined'])->assertStatus(402); $fake->assertSentTo('telegram', fn (LogPayload $payload, FormattedMessage $message) => $payload->message === 'Payment failed' && $payload->context['order_id'] === 42 ); }
| Method | Checks |
|---|---|
assertSent(?callable) |
at least one log was sent (matching the callback) |
assertSentTo($driver, ?callable) |
a log was sent to that driver |
assertNotSent(callable) |
no sent log matches the callback |
assertSentCount($n) / assertNothingSent() |
how many logs were sent |
sent(?callable) |
the recorded logs (driver, payload, message) for your own assertions |
Callbacks receive the masked LogPayload, the FormattedMessage the driver's formatter produced,
and the driver name. To test the fallback driver or LogDispatchFailed listeners, make a driver
fail: LogCenter::fake()->failing('telegram').
Testing
composer test # run the PHPUnit suite composer lint # check code style (Pint) composer lint:fix # fix code style
All transport tests use Laravel's Http::fake() โ no real network calls are made.
Changelog
Please see CHANGELOG for details 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). Please see License File for more information.