hddev / laravel-request-logger
Structured HTTP request logging for Laravel: request lifecycle events, correlation IDs, sensitive data redaction, pluggable profiles and writers.
Requires
- php: ^8.3
- illuminate/contracts: ^12.0
- illuminate/http: ^12.0
- illuminate/log: ^12.0
- illuminate/routing: ^12.0
- illuminate/support: ^12.0
- monolog/monolog: ^3.0
- psr/log: ^3.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^10.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
This package is auto-updated.
Last update: 2026-07-27 10:31:44 UTC
README
Structured HTTP request logging for Laravel 12+. Logs the full request lifecycle (request.start, request.end, request.exception) as structured context, correlates everything with a request ID propagated through the Laravel Context and returned in a response header, and redacts sensitive data recursively.
Built for platforms where logs are queried, not read: every entry carries a machine-parsable event, a request_id, HTTP metadata, user identity and performance figures. On Azure Container Apps + Log Analytics, this makes any request traceable end to end with a single KQL query.
Why not spatie/laravel-http-logger?
Spatie's package logs the incoming request only. This package logs the lifecycle: start and end as two correlated events with status code, duration, memory peak, resolved route and authenticated user, plus a dedicated exception event. It borrows Spatie's best ideas: pluggable LogProfile / LogWriter classes and header sanitization.
Installation
composer require hddev/laravel-request-logger
Publish the config file if you need to customize beyond env variables:
php artisan vendor:publish --tag=request-logger-config
Usage
Register the middleware in bootstrap/app.php. Register it before auth middlewares so the whole request is timed; user identity is still captured on request.end because auth has run by then.
use HDDev\LaravelRequestLogger\Middleware\LogRequest; ->withMiddleware(function (Middleware $middleware): void { $middleware->prepend(LogRequest::class); })
Or on a group only:
$middleware->appendToGroup('api', LogRequest::class);
What gets logged
[GET] api/demands request.start query params, client info
[GET] demands.index (200) request.end status, route, user, duration, memory
[POST] demands.store (EXCEPTION) request.exception exception class/message/file/line
Note on exceptions: with Laravel's exception handler active, throwables are rendered into a 500 response inside the routing pipeline, so they surface as a request.end error event (the handler's own report shares the same request_id through the Context). The request.exception event is a safety net for throwables that escape the handler (handler unbound, rethrow, some Octane edge cases).
Every response also receives an X-Request-ID header, and request_id is added to the Laravel Context, so it appears on every log line written during the request and is propagated to queued jobs.
Configuration
Everything ships with sensible defaults and is driven by env variables:
| Variable | Default | Description |
|---|---|---|
REQUEST_LOGGER_ENABLED |
true |
Master switch |
REQUEST_LOGGER_CHANNEL |
null |
Dedicated log channel (falls back to default) |
REQUEST_LOGGER_LOG_BODY |
false |
Log the sanitized request body on start |
REQUEST_LOGGER_LOG_HEADERS |
false |
Log sanitized headers on start |
REQUEST_LOGGER_REQUEST_ID_HEADER |
X-Request-ID |
Response header carrying the request ID |
REQUEST_LOGGER_FAIL_SILENTLY |
true |
Never break a request on logging failure |
Exclusions (paths with wildcards, route names, HTTP methods), sensitive keys/headers and the redaction placeholder are configured in config/request-logger.php.
Dedicated channel (recommended in production)
Keep request logs separate, queryable, and cheap to purge. Example config/logging.php channel writing JSON to stderr (Docker/Container Apps friendly):
'channels' => [ 'requests' => [ 'driver' => 'monolog', 'handler' => Monolog\Handler\StreamHandler::class, 'formatter' => Monolog\Formatter\JsonFormatter::class, 'with' => ['stream' => 'php://stderr'], ], ],
Then set REQUEST_LOGGER_CHANNEL=requests.
Running Octane with the FrankenPHP server? Use OctaneSafeJsonFormatter instead of JsonFormatter. See Octane.
Extending
Three contracts, all swappable from config:
'log_profile' => App\Logging\MyLogProfile::class, // decides WHAT to log 'log_writer' => App\Logging\MyLogWriter::class, // decides HOW to log it 'request_id_generator' => App\Logging\MyIdGenerator::class, // decides the correlation ID
DefaultLogWriter exposes protected extension points (logger(), logLevel(), title(), performance()) so partial overrides stay small.
Full architecture walkthrough and extension use cases: docs/extending.md.
Octane
The middleware resolves its collaborators per request and stores the request ID in the Laravel Context, which Octane resets between requests. No state leaks across workers. Two consecutive requests always get distinct request IDs (covered by tests).
FrankenPHP: use OctaneSafeJsonFormatter on stderr
With the FrankenPHP server, Octane reads the worker's stderr and parses every line as a Caddy log entry. A line that decodes to JSON but has no msg key is replaced by ERROR unknown error. before it ever reaches the container log collector.
Monolog's JsonFormatter writes message, not msg. So the recommended stderr channel above silently loses every request log: the middleware runs, the X-Request-ID header is returned, but the structured lines never make it out. Nothing throws, and fail_silently has no effect, because the write itself succeeds.
Point the channel at OctaneSafeJsonFormatter, which wraps each record in the envelope Octane forwards verbatim:
'channels' => [ 'requests' => [ 'driver' => 'monolog', 'handler' => Monolog\Handler\StreamHandler::class, 'formatter' => HDDev\LaravelRequestLogger\Formatters\OctaneSafeJsonFormatter::class, 'with' => ['stream' => 'php://stderr'], ], ],
The emitted line stays a single JSON object per record, with the original payload nested under msg:
{"msg":"{\"message\":\"[GET] api/demands\",\"context\":{\"event\":\"request.start\",\"request_id\":\"a25ca186\"},\"level_name\":\"INFO\",\"type\":\"raw\"}"}
Log collectors that index raw stderr lines still see valid JSON. To query the inner payload, parse the msg string as JSON (for example parse_json(msg) in KQL).
You only need this when writing to stderr under FrankenPHP. File channels, stdout-based drivers and the Swoole/RoadRunner servers are unaffected, and JsonFormatter remains the right choice there.
Testing
composer test # pest composer test-coverage # pest --coverage --min=100 composer analyse # phpstan level max composer format # pint
Changelog
See CHANGELOG.
License
The MIT License (MIT). See LICENSE.