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

Monolog Bridge Laravel Package

bref/monolog-bridge

A Monolog formatter for AWS Lambda that makes CloudWatch logs both readable and filterable. It outputs structured JSON with message, level, exception, context, and extra fields, enabling Logs Insights queries by level or exception class.

View on GitHub
Deep Wiki
Context7

Getting Started

0. Check whether you already have it. This formatter ships inside Bref's Laravel bridge and Symfony bridge. If your composer.json has bref/laravel-bridge, skip installation — you only need to enable the formatter. Installing bref/monolog-bridge explicitly alongside a bridge risks a version constraint conflict.

1. Confirm the target channel writes to stderr. The formatter is only effective on the handler that writes php://stderr. In Laravel, check config/logging.php:

'stderr' => [
    'driver'    => 'monolog',
    'level'     => env('LOG_LEVEL', 'debug'),
    'handler'   => Monolog\Handler\StreamHandler::class,
    'formatter' => env('LOG_STDERR_FORMATTER'),
    'with'      => ['stream' => 'php://stderr'],
],

If your LOG_STDERR_FORMATTER is not read there, the env var below does nothing.

2. Point the env var at the class in serverless.yml:

provider:
    environment:
        LOG_CHANNEL: stderr
        LOG_STDERR_FORMATTER: Bref\Monolog\CloudWatchFormatter

3. Deploy and read one log line. Trigger an invocation, open CloudWatch, and expect roughly:

INFO   This is a log message   {"message":"This is a log message","level":"INFO"}

(2.x prefixes the Lambda request ID as well, so lines start with the invocation identifier rather than INFO.)

4. First real use case — find your errors. Paste this into CloudWatch Logs Insights and save it as a query:

fields @timestamp, @message
| filter level = 'ERROR'
| sort @timestamp desc
| limit 50

Once that returns rows, the formatter is wired correctly. The second query to save is the one you'll actually live in daily:

fields @timestamp, message, exception.class, exception.file
| filter level = 'ERROR'
| stats count() by exception.class

5. Do a local dry run. Run LOG_STDERR_FORMATTER='Bref\Monolog\CloudWatchFormatter' php artisan about 2>&1 (or any command that logs) to see the exact Lambda output without deploying. Don't put that env var in .env — see gotchas.


Implementation Patterns

Framework configuration

Laravel — the env var from serverless.yml is the whole story. Make sure LOG_CHANNEL resolves to stderr (Bref's Laravel bridge does this for you); if you run stack with single/daily members, those members keep their own formatters and will write plain text or local files that vanish with the container.

Symfony — configure the handler directly in config/packages/prod/monolog.yaml:

monolog:
    handlers:
        file:
            type: stream
            path: 'php://stderr'
            level: info
            formatter: 'Bref\Monolog\CloudWatchFormatter'

Plain PHP / Slim / Mezzio — attach it to a stderr stream handler and nothing else:

$handler = new Monolog\Handler\StreamHandler('php://stderr', Monolog\Logger::INFO);
$handler->setFormatter(new Bref\Monolog\CloudWatchFormatter);
$logger  = new Monolog\Logger('default');
$logger->pushHandler($handler);

Make every line self-describing with context

The single highest-value habit with this formatter is pushing invocation-scoped metadata into context, because it lands in the JSON object on every line and becomes filterable:

// app/Http/Middleware/LambdaLogContext.php
public function handle($request, Closure $next)
{
    Log::withContext([
        'route'        => $request->route()?->getName(),
        'user_id'      => $request->user()?->id,
        'tenant'       => $request->header('X-Tenant'),
    ]);

    return $next($request);
}

Then | filter tenant = 'acme' correlates a whole invocation's logs without relying on the request ID text prefix. Do the same at the top of queued job handlers and artisan commands — those are separate Lambda invocations with separate log streams, so the context is the only thing that ties a job's lines together.

Logging exceptions so the structured exception object appears

The formatter promotes a throwable found in the exception context key into a top-level JSON object with class, message, code, file, and trace. That only happens if you pass it there:

try {
    $this->charge($order);
} catch (PaymentFailed $e) {
    Log::error('Payment failed for order '.$order->id, ['exception' => $e]);
    throw $e;
}

report($e) in Laravel's exception handler does this for you, so uncaught exceptions already arrive queryable. Passing the exception as a bare context value under another key (e.g. ['e' => $e]) will not produce the structured block.

Query cookbook for CloudWatch Logs Insights

-- all errors of one exception class, newest first
fields @timestamp, message, exception.file
| filter level = 'ERROR' and exception.class = 'PaymentFailed'
| sort @timestamp desc
| limit 100

-- error rate per 5 minutes for a dashboard
filter level = 'ERROR'
| stats count() as errors by bin(5m)

-- everything from one tenant in one invocation
filter tenant = 'acme' and requestId = '...'
| sort @timestamp asc

Treat these as saved queries checked into the repo next to the code, not as ad-hoc hunting.

Wire up a metric filter and alarm

Structured levels are what make this worth the effort: create a CloudWatch metric filter on the error pattern ({ $.level = "ERROR" }, or a plain string match on "level":"ERROR" if the JSON pattern doesn't bind — see gotchas) and alarm on it. That converts this package from "prettier logs" into actual detection.


Gotchas and Tips

The env var only affects the stderr channel. This is the number one cause of "I installed it and nothing changed". If config/logging.php's stderr entry hardcodes a formatter instead of env('LOG_STDERR_FORMATTER'), or if LOG_CHANNEL points at stack, you'll keep seeing monolog's default line format. Verify by grepping the deployed log line for a trailing JSON object.

Don't set LOG_STDERR_FORMATTER globally. Put it in serverless.yml (or the environment block of a vendor/bin/bref invocation), not in .env or phpunit.xml — otherwise local terminal output and test failure output become wall-to-wall JSON, and any test asserting on log text breaks.

The message is duplicated. It appears once in the human-readable prefix and once as "message" inside the JSON. CloudWatch bills per GB ingested, so you're paying roughly double for the message portion. For chatty debug-level logging this matters; keep prod at info/warning and be deliberate about what you log.

Timestamps and channel names are intentionally omitted. CloudWatch supplies @timestamp. That's fine for real-time logging but means event time ≈ ingestion time — if you buffer or replay logs, the ordering is CloudWatch's, not your application's. If you need the true application time, add it to context. Likewise, the Monolog channel is dropped: if you run multiple logger names inside one function, distinguish them with a context key rather than withName().

level is an uppercase string. filter level = 'error' returns nothing. Same for exception.class, which is the fully-qualified class name as a string.

Mixed text + JSON breaks JSON-typed tooling. The line is not a JSON document — it's text followed by a JSON object. CloudWatch Logs Insights handles this well, but metric filters, subscription filters, and third-party log shippers that expect the entire event to be JSON may not extract $.level. If a JSON pattern filter doesn't match, fall back to a substring match ("level":"ERROR") or use the parse @message command with a regex in Insights to pull out the trailing object.

Watch the 256 KB per-event limit. Pass a large context payload (an Eloquent model's toArray(), a full request body, a big query result) and Lambda truncates or splits the line — the JSON tail gets cut off and field extraction silently stops working for those records. Sanitize what you log; prefer IDs over hydrated objects. This is also the fastest way to leak secrets into a log group whose retention you forgot to set.

Non-UTF-8 and circular references break the JSON half. Binary strings (file contents, encrypted blobs) and objects with cycles make json_encode fail; depending on the Monolog version you get a truncated line or an empty JSON object with no warning. If you must log raw payloads, base64 them first. Keep an eye on missing {...} tails as a symptom.

Leave Lambda's own log format set to Text. Newer Lambda advanced logging controls can wrap or convert runtime output to JSON, which stacks a second encoding layer around a line that is already half-JSON and confuses field discovery. Keep it plain.

Prefer processors/context over subclassing for custom fields. Anything you add via Log::withContext() or a Monolog processor lands under context/extra and is immediately queryable — no forks, no upgrade friction. Only subclass CloudWatchFormatter (if it's not final in your version) when you genuinely need to change the line shape, e.g. dropping the request-ID prefix for a custom log pipeline; you then own keeping up with format changes across minor releases.

Version and runtime constraints. 1.0.1 is the branch for PHP 8.0/8.1 with Monolog 2; 2.0 requires PHP 8.2+ and adds the request-ID prefix. Pin deliberately: the prefix change alters column offsets, so any brittle string assertions or log-scraping grep patterns need updating on the 2.0 jump.

Debugging the formatter itself. When output looks wrong, isolate the layer: (new Bref\Monolog\CloudWatchFormatter)->format($record) in a tinker session shows exactly what the formatter produces for a hand-built LogRecord, separating formatter bugs from handler/channel misconfiguration from CloudWatch parsing quirks. Work outward in that order — most "CloudWatch isn't parsing" reports turn out to be the handler writing to stdout or to a file instead of php://stderr.

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.
takashato/laravel-vietqr
aitor24/laralang
kraenzle-ritter/entity-relations
dindigital/din-image
pomirleanu/image-colors
sawmainek/laravel-api-generator
delatbabel/jsonresponses
mouhamedfd/permissions-generator
eduplus/qr
laravel2580/idehelpers
delatbabel/viewpages
anavel/gettext
muhamadrezaar/highcharts
delatbabel/keylists
frankkessler/salesforce-laravel-oauth2-rest
anavel/foundation
wdmtech/enviricons
eii/laravel-captcha
muka/shape-reader
tdt/json