korioinc / laravel-exception-viewer
Aggregate Laravel exceptions into the database with masked request context for operations and LLM analysis.
Package info
github.com/korioinc/laravel-exception-viewer
pkg:composer/korioinc/laravel-exception-viewer
Fund package maintenance!
Requires
- php: ^8.2
- illuminate/console: ^11.0||^12.0||^13.0
- illuminate/contracts: ^11.0||^12.0||^13.0
- illuminate/database: ^11.0||^12.0||^13.0
- illuminate/http: ^11.0||^12.0||^13.0
- illuminate/support: ^11.0||^12.0||^13.0
- monolog/monolog: ^3.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^11.0.0||^10.0.0||^9.0.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- spatie/laravel-ray: ^1.35
README
Laravel Exception Viewer keeps Laravel's native exception reporting flow intact, stores aggregated exception rows in exception_logs, ships with a Blade viewer, and exposes markdown endpoints that work well with curl, automation, and LLM workflows.
What It Does
- Records exceptions that reach Laravel's exception reporting flow
- Aggregates repeated exceptions into one fingerprinted row
- Stores the latest exception text plus masked context
- Captures HTTP exceptions
- Captures CLI exceptions
- Captures queued job exceptions, including job metadata and payload
- Provides a Blade viewer at
/exception-viewer - Provides markdown export endpoints for one exception or all exceptions
- Can dispatch Discord alarm jobs for repeated exceptions
- Prunes exception logs whose latest occurrence is at least 14 days old
Installation
Requirements:
- PHP 8.2+
- Laravel 11, 12, or 13
Install the package with Composer:
composer require korioinc/laravel-exception-viewer
Laravel auto-discovers the package service provider, so no manual provider registration is required.
Publish the default install set in one shot:
php artisan vendor:publish --tag="exception-viewer-install"
php artisan migrate
This publishes:
- config
- migrations
Or publish only what you need:
php artisan vendor:publish --tag="exception-viewer-migrations"
php artisan migrate
php artisan vendor:publish --tag="exception-viewer-config"
php artisan vendor:publish --tag="exception-viewer-views"
If you explicitly want every publishable artifact registered by the provider, including views, you can still use:
php artisan vendor:publish --provider="Korioinc\ExceptionViewer\ExceptionViewerServiceProvider"
Published views are placed at:
resources/views/vendor/exception-viewer/pages/index.blade.php
If you store exception_logs on a dedicated database connection, set exception-viewer.database_connection before running php artisan migrate. The published migration uses that configured connection when it creates or drops the table.
Configuration
Published config:
use Korioinc\ExceptionViewer\Http\Middleware\DenyInProduction; return [ 'enabled' => env('EL_ENABLED', true), 'database_connection' => null, 'source' => [ 'key' => env('EL_SOURCE_KEY', ''), 'label' => env('EL_SOURCE_LABEL', 'Local App'), ], 'forwarding' => [ 'enabled' => env('EL_FORWARDING_ENABLED', false), 'mode' => env('EL_FORWARDING_MODE', 'sync'), 'endpoint' => env('EL_FORWARDING_ENDPOINT', ''), 'api_key' => env('EL_FORWARDING_API_KEY', ''), 'queue' => env('EL_FORWARDING_QUEUE', null), 'timeout' => (float) env('EL_FORWARDING_TIMEOUT', 2), 'tries' => (int) env('EL_FORWARDING_TRIES', 3), 'backoff' => env('EL_FORWARDING_BACKOFF', 60), ], 'receiver' => [ 'enabled' => env('EL_RECEIVER_ENABLED', false), 'route_path' => env('EL_RECEIVER_ROUTE_PATH', 'api/exception-viewer/exceptions'), 'api_keys' => env('EL_RECEIVER_API_KEYS', ''), 'middleware' => ['api'], ], 'alarm_enabled' => env('EL_ALARM_ENABLED', env('EL_ALARM_ENALBED', true)), 'log_time_frame' => (int) env('EL_LOG_TIME_FRAME', 3), 'log_per_time_frame' => (int) env('EL_LOG_PER_TIME_FRAME', 2), 'delay_between_alarms' => (int) env('EL_DELAY_BETWEEN_ALARMS', 5), 'notification_message' => env('EL_NOTIFICATION_MESSAGE', ''), 'discord_webhook_url' => env('EL_DISCORD_WEBHOOK_URL', ''), 'notification_title' => 'Log Alarm Notification', 'route_path' => 'exception-viewer', 'assets_path' => 'vendor/exception-viewer', 'middleware' => [ 'web', DenyInProduction::class, ], 'request_context' => [ 'enabled' => true, 'masked_keys' => [ 'authorization', 'x-api-key', 'password', ], 'max_headers_size' => null, 'max_payload_size' => null, ], ];
Key options:
enabled: master switch for recording and alarm evaluationdatabase_connection: optional connection forexception_logs;nulluses the app default, and the published migration uses this connection toosource.key: stable service identity used for local storage and central forwarding; required when forwarding is enabledsource.label: display-only label for the local source in the Blade viewer; defaults toLocal Appforwarding.enabled: stores locally first, then forwards to the central receiver when all forwarding settings are presentforwarding.mode:syncsends the HTTP request during exception reporting, whilequeuedispatches a forwarding jobforwarding.endpoint,forwarding.api_key: central receiver URL and bearer tokenforwarding.queue,forwarding.timeout,forwarding.tries,forwarding.backoff: queue and HTTP delivery controlsreceiver.enabled: opens the central machine-to-machine receiver endpoint when truereceiver.route_path,receiver.api_keys,receiver.middleware: central route path, accepted bearer keys, and API middlewareroute_path: viewer route prefixassets_path: asset base path exposed to the Blade viewermiddleware: viewer route middleware stack; default is['web', DenyInProduction::class]request_context.enabled: enables request or execution context capturerequest_context.masked_keys: keys masked before headers or payload are stored; the default list isauthorization,x-api-key, andpasswordrequest_context.max_headers_size,request_context.max_payload_size: optional truncation limits
If you published config/exception-viewer.php before source.label was
available, add the source.label key or republish the config before relying on
EL_SOURCE_LABEL. Laravel merges published config arrays shallowly, so an older
published source array can hide the package default.
If you published the package views before source.label, the all-source purge
confirmation, or row-level delete controls were added, update your published
resources/views/vendor/exception-viewer/pages/index.blade.php copy or
republish the view. Older published views may not include the
all_source_confirmation=all field required by the clear-all action or the
per-row delete forms required to delete individual exception rows after upgrade.
Alarm delivery and cache failures are swallowed so the package never interrupts Laravel's native exception reporting flow.
Recording Model
The package records one aggregated row per exception fingerprint in exception_logs.
Only exceptions that reach Laravel's exception reporting flow are recorded.
Stored columns:
source_keyreceived_atkeynamemessagefilelineraw_exceptionrequest_methodrequest_endpointrequest_headersrequest_payloadcountlatest_at
Fingerprinting currently uses:
- exception class
- exception message
- file
- line
- the first part of the stack trace
Repeated local exceptions increment count and refresh the latest exception text and context fields. The central receiver stores aggregate snapshots by source_key plus key, so two services can report the same fingerprint key without overwriting each other.
Log Retention
The package registers exception-viewer:prune with Laravel's scheduler. By default, the command runs daily and deletes exception_logs rows whose latest_at value is at least 14 days old.
Captured Context
The package records different execution contexts:
- HTTP exception
request_method: HTTP method such asGETorPOSTrequest_endpoint: request path or URLrequest_headers: masked request headersrequest_payload: masked request payload
- CLI exception
- request-specific fields can be empty because there is no HTTP request
- Job exception
request_method:JOBrequest_endpoint: queued job class namerequest_headers: queue metadata such asqueue,attempts,job_id,job_namerequest_payload: masked job payload- Synchronous jobs dispatched during an HTTP request are still recorded as job exceptions, not as HTTP request exceptions
By default, authorization, x-api-key, and password are masked before storage. Add keys such as token, secret, cookie, or set-cookie to request_context.masked_keys if your app needs them masked too.
Viewer
By default, the Blade viewer is available at:
/exception-viewer
The default middleware stack includes DenyInProduction, so the viewer returns 404 in production unless you explicitly override the middleware.
In shared non-production environments such as staging, add your own access control middleware like auth or an internal allowlist. By default, the package does not add authentication on top of web.
The viewer includes:
- left sidebar grouped by exception
name - latest-first aggregated exception list
- expandable detail rows
- copy button for markdown output
- link copy button for one exception
- row-level delete button that requires a second click on the check icon before deleting one exception row
- all-export copy button
- purge action for clearing the current source, plus a separate all-source clear action that requires typing
allto confirm
Markdown endpoints:
/exception-viewer/all
/exception-viewer/{key}
These endpoints return text/markdown.
JSON summary endpoint:
/exception-viewer/json
This endpoint returns application/json for automation and LLM clients. It uses the same viewer middleware as the Blade and markdown endpoints, including the default production 404. The response exposes service keys and exception class names, so treat it as operationally sensitive and protect it with application authentication or an internal allowlist in shared environments.
Example response:
[
{
"name": "local-app",
"exceptions": [
{
"name": "RuntimeException",
"count": 7,
"latest_at": "2026-03-25 12:30:00"
},
{
"name": "InvalidArgumentException",
"count": 2,
"latest_at": "2026-03-25 11:30:00"
}
],
"total_count": 2,
"total_error_count": 9
}
]
total_count is the number of discovered exception summary items for that service. total_error_count is the sum of the exception item count values.
Markdown Output
The single-exception endpoint returns a markdown document shaped like this:
# Exception - Name: `RuntimeException` - Message: Example failure while processing checkout - File: `/var/www/html/app/Services/CheckoutService.php` - Line: 184 ## Request - Method: POST - Endpoint: /api/checkout ## Headers ~~~json {"authorization":"[MASKED]","x-request-id":"trace-123"} ~~~ ## Payload ~~~json {"order_id":1001,"password":"[MASKED]"} ~~~ ## Context ~~~text [2026-03-26 10:00:00] stack.ERROR: Example failure while processing checkout ... ~~~
If request context is missing, the request-related sections are omitted entirely.
Markdown output includes the source key when an exception row has one.
Central Receiver
Install the package on every source service and on one central bridge service.
Source services keep writing to their own exception_logs table. When
forwarding is enabled, they also send a snapshot to the central bridge.
Source Service
Set these values on each service that sends exceptions:
EL_SOURCE_KEY=service-a EL_SOURCE_LABEL="Service A" EL_FORWARDING_ENABLED=true EL_FORWARDING_ENDPOINT=https://central.example.com/api/exception-viewer/exceptions EL_FORWARDING_API_KEY=service-a-secret
EL_SOURCE_KEY is the stable source identity stored locally and sent to the
central receiver. The central database stores this key only.
EL_SOURCE_LABEL is an optional display-only label for the source service's own
Blade viewer. It does not change forwarded payloads or central storage.
EL_FORWARDING_API_KEY must be one of the keys configured on the central
bridge service.
The default EL_FORWARDING_MODE=sync performs the central HTTP request
immediately after local exception storage. Delivery failures are swallowed so
Laravel's native exception flow is not interrupted, but they are not retried by
the package.
For services with queue workers, use asynchronous forwarding:
EL_FORWARDING_MODE=queue
Central Bridge Service
Set these values on the service that receives forwarded exceptions:
EL_RECEIVER_ENABLED=true EL_RECEIVER_API_KEYS=service-a-secret,service-b-secret
The receiver URL is:
https://central.example.com/api/exception-viewer/exceptions
The source service sends EL_FORWARDING_API_KEY as a bearer token. The central
bridge accepts it when it is listed in EL_RECEIVER_API_KEYS.
View received exceptions at:
https://central.example.com/exception-viewer
Receiver API:
POST /api/exception-viewer/exceptions
Authorization: Bearer <api-key>
Content-Type: application/json
Accept: application/json
Accepted payloads return 202 with accepted, source_key, key, and count. Missing or invalid API keys return 401. Malformed payloads or unsupported payload versions return 422. When receiver is disabled, the endpoint returns 404.
The central viewer shows per-source tabs and defaults to the local source. Markdown exports continue to work and include the source key for source-aware rows.
Security notes:
- Keep
EL_RECEIVER_API_KEYSandEL_FORWARDING_API_KEYin secret storage. - Prefer one receiver key per source so a single service can be rotated independently.
- Do not put the receiver route behind CSRF-only
webmiddleware; it is a JSON machine-to-machine endpoint. - Protect the central viewer separately with application auth or internal access controls before using it in production.
- Forwarding uses the already stored request context, so configured
request_context.masked_keysapply before remote delivery.
LLM Workflow
The markdown endpoints are designed to work with curl, scripts, and LLM tools.
Use the JSON summary when you want a compact service-by-service exception list:
http://localhost/exception-viewer/json
Use all exceptions when you want broad triage:
http://localhost/exception-viewer/all
Use one exception when you want focused analysis:
http://localhost/exception-viewer/629a80482b8e84f9412715b427b8a1d9db08845ba59071352d9f557d444dc2db
Example curl usage:
curl http://localhost/exception-viewer/json
curl http://localhost/exception-viewer/all
curl http://localhost/exception-viewer/629a80482b8e84f9412715b427b8a1d9db08845ba59071352d9f557d444dc2db
Example prompt for an LLM:
Read this exception summary and identify which service and exception class need attention first:
http://localhost/exception-viewer/json
For broad markdown triage:
Read this exception export and explain the root cause, likely blast radius, and the smallest safe fix:
http://localhost/exception-viewer/all
For a single issue:
Read this exception detail and propose a fix with verification steps:
http://localhost/exception-viewer/629a80482b8e84f9412715b427b8a1d9db08845ba59071352d9f557d444dc2db
If your app is not running on localhost, replace the host and port with the actual viewer URL.
Alarm
If EL_ALARM_ENABLED=true and EL_DISCORD_WEBHOOK_URL is configured, the package can dispatch a Discord alarm job.
Supported env keys:
EL_ENABLED=true EL_ALARM_ENABLED=true EL_LOG_TIME_FRAME=3 EL_LOG_PER_TIME_FRAME=2 EL_DELAY_BETWEEN_ALARMS=5 EL_NOTIFICATION_MESSAGE= EL_DISCORD_WEBHOOK_URL=
Alarm behavior:
- only exceptions handled by this package are considered
- alarms are grouped by exception fingerprint
- alarms are sent immediately while the fingerprint is below the configured limit
- once the same fingerprint has been sent
EL_LOG_PER_TIME_FRAMEtimes withinEL_LOG_TIME_FRAMEminutes, further alarms are blocked forEL_DELAY_BETWEEN_ALARMSminutes - the delay is applied per fingerprint
- alarm dispatch always happens through a queued job
- Discord delivery is optional and never interrupts Laravel's native exception flow
- if
notification_messageis empty, the package sendsLOG_LEVEL,LOG_MESSAGE,LOG_FILE, andLOG_LINE - alarm messages always include an
Open in Viewerdetail link; whether that URL is reachable depends on your viewer middleware configuration
Example with the current defaults:
- up to 2 alarms can be sent within 3 minutes for the same fingerprint
- after that, the same fingerprint is muted for 5 minutes
Queue and Async Delivery
Alarm dispatch uses queued jobs. Central forwarding uses queued jobs when
EL_FORWARDING_MODE=queue and sends inline when EL_FORWARDING_MODE=sync.
If the host app uses:
QUEUE_CONNECTION=sync
queued jobs still run in the same process.
For real async delivery, use a non-sync queue such as:
QUEUE_CONNECTION=redis
Then run a worker or Horizon:
php artisan horizon
or
php artisan queue:work
If you use Redis plus Horizon, Discord delivery and central forwarding happen in Horizon workers.
Publish Commands
Publish all package assets:
php artisan vendor:publish --provider="Korioinc\ExceptionViewer\ExceptionViewerServiceProvider"
Or publish individual assets:
php artisan vendor:publish --tag="exception-viewer-config" php artisan vendor:publish --tag="exception-viewer-views" php artisan vendor:publish --tag="exception-viewer-migrations"
Testing
composer test
Local Development Data
Refresh the Testbench database and seed representative exception rows:
composer dev:fresh composer dev:seed
The seeder inserts local HTTP, local queue, and forwarded source samples into exception_logs.
Changelog
Please see CHANGELOG for more information on what has changed recently.
Credits
License
The MIT License (MIT). Please see License File for more information.