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

Cwh Laravel Package

phpnexus/cwh

A Monolog handler for Amazon CloudWatch Logs. phpnexus/cwh lets PHP applications send PSR-3 log records to CloudWatch through the AWS SDK, with configurable log groups and streams, batching, and flexible level handling for production logging.

View on GitHub
Deep Wiki
Context7

Getting Started

phpnexus/cwh gives Monolog a CloudWatch handler that batches records and ships them to an AWS CloudWatch Logs group/stream. You stay in Monolog-land; the package just swaps the sink.

Install and wire up (5 minutes):

composer require phpnexus/cwh aws/aws-sdk-php
use Aws\CloudWatchLogs\CloudWatchLogsClient;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use PhpNexus\Cwh\Handler\CloudWatch;

$client = new CloudWatchLogsClient([
    'region'  => getenv('AWS_REGION'),
    'version' => 'latest',
    // credentials come from env/instance role by default
]);

$handler = new CloudWatch(
    $client,
    'my-app/production',                 // log group
    gethostname() . '-' . date('Y-m-d'), // log stream (make it unique per writer)
    14,                                  // retention in days
    10000,                               // batch size (records per PutLogEvents call)
    ['env' => 'production']              // tags applied to the group
);

$logger = new Logger('app');
$logger->pushHandler(new StreamHandler('php://stderr', Logger::DEBUG)); // local safety net
$logger->pushHandler($handler);

IAM the task/instance role needs: logs:CreateLogGroup, logs:CreateLogStream, logs:DescribeLogStreams, logs:PutLogEvents, logs:PutRetentionPolicy, and logs:TagLogGroup (or logs:TagResource). Miss any of these and the handler fails on first write, not at construction — which is the #1 source of "it silently did nothing."

Where to look first: src/Handler/CloudWatch.php. Read write(), createLogGroup(), createLogStream(), and close(). Almost everything you'll want to tune (batch flush, stream naming, retention) lives in those four methods.

First real use case: replace your production StreamHandler (or add it alongside) so application errors land in a queryable group like my-app/production with 14-day retention. Then use Logs Insights (fields @timestamp, message | filter level >= 400) instead of SSHing into boxes to tail files.

Implementation Patterns

Laravel: use a custom channel with a factory. Laravel's handler + handler_with config can't easily inject a pre-built AWS client, so prefer via:

// config/logging.php
'cloudwatch' => [
    'driver'    => 'custom',
    'via'       => App\Logging\CloudWatchFactory::class,
    'group'     => env('CLOUDWATCH_LOG_GROUP', 'my-app/' . env('APP_ENV')),
    'retention' => (int) env('CLOUDWATCH_RETENTION', 14),
],
class CloudWatchFactory
{
    public function __invoke(array $config): Logger
    {
        $client = app(\Aws\CloudWatchLogs\CloudWatchLogsClient::class);

        $handler = new \PhpNexus\Cwh\Handler\CloudWatch(
            $client,
            $config['group'],
            gethostname() . '-' . date('Y-m-d'),
            $config['retention'],
            10000,
            ['app' => config('app.name'), 'env' => app()->environment()]
        );
        $handler->setFormatter(new \Monolog\Formatter\JsonFormatter());

        return tap(new Logger('cloudwatch'), fn ($l) => $l->pushHandler($handler));
    }
}

Register CloudWatchLogsClient as a singleton in a service provider so every channel reuses one HTTP connection pool.

Stream naming is the most important design decision. Use one stream per writer, not one stream per application:

  • Web/FPM: gethostname() . '-' . date('Y-m-d') (one per box, per day)
  • ECS/Fargate: $_ENV['HOSTNAME'] (the task ID) - date
  • CLI/queue workers: php-cli- . gethostname() . - . getmypid() - date
  • Lambda: don't do this at all (see Gotchas)

Structured formatter. Defaults produce human-readable lines; for Logs Insights you want JSON:

$handler->setFormatter(new JsonFormatter(JsonFormatter::BATCH_MODE_JSON, true));

Then filter @message like /"level":500/ or query context.user_id directly.

Stack with a failure guard. Wrap the CloudWatch handler in WhatFailureGroupHandler so an AWS outage never takes down the request:

$logger->pushHandler(new WhatFailureGroupHandler([
    $handler,                       // best-effort CloudWatch
    new StreamHandler('php://stderr'), // always succeeds
]));

Tune batchSize to your volume. The handler buffers until batchSize records accumulate or close()/shutdown fires. Larger batches = fewer PutLogEvents calls (5 req/s/stream limit) but more memory and more loss if the process dies hard. 10,000 is the AWS hard cap per call; going above it doesn't help. For low-volume services, 100–500 gives near-real-time logs at the cost of more requests.

Test against a mock client. Use the SDK's MockHandler in unit tests instead of hitting AWS:

$mock = new \Aws\MockHandler([new \Aws\Result(['nextSequenceToken' => 'x'])]);
$client = new CloudWatchLogsClient([
    'region' => 'us-east-1', 'version' => 'latest', 'handler' => $mock,
]);

Symfony: register the handler as a service, tag it monolog.logger, and configure monolog.handlers.cloudwatch.type: service.

Gotchas and Tips

Out-of-order rejection. PutLogEvents rejects batches where events aren't chronologically ordered — InvalidParameterException: Log events in a single PutLogEvents request must be in chronological order. This almost always means two processes are writing to the same stream. Fix by unique stream names (above), not by sorting.

The last batch can vanish. Records only flush at batchSize or on destruct/shutdown. Fatal errors (OOM, SIGKILL, uncaught exceptions during shutdown) lose the buffer. For critical paths, call $handler->close() explicitly in your exception handler, or flush at a smaller batch size.

Exceptions in __destruct are nasty. If the flush during shutdown throws (throttling, expired credentials), you can get "Exception thrown without a stack frame" or a fatal in shutdown. Always wrap in WhatFailureGroupHandler or override close() to swallow/log locally.

IAM putRetentionPolicy side effect. The handler sets retention on the group, which is group-wide, not stream-wide. If your infra team manages retention centrally, passing a non-zero value here silently overwrites it. Pass 0 (or whatever your version treats as "skip") if retention is managed elsewhere — check the constructor in your pinned version, because semantics changed across 2.x/3.x.

Clock skew. CloudWatch rejects events more than 14 days old or >2 hours in the future with InvalidParameterException. Containers with drifted clocks or apps logging a stored datetime cause this. Keep NTP/chrony healthy and log real time, not replay time.

Payload limits. 256 KB per event, 1 MB per PutLogEvents request, 10,000 events per request. A single giant exception trace or json_encoded object can exceed the per-event cap and fail the whole batch. Set JsonFormatter::setMaxNormalizeDepth() and trim context aggressively; large blobs go to S3, not CloudWatch.

Don't double-log on Lambda. The Lambda runtime already ships stdout/stderr to /aws/lambda/<fn>. Adding this handler creates a second copy and wastes PutLogEvents quota. Use it in Lambda only if you intentionally want a different group/stream layout.

Monolog version pinning. phpnexus/cwh 2.x targets Monolog 1/2, 3.x targets Monolog 3. A Composer update that bumps Monolog across the boundary can leave you on an incompatible handler; pin both.

Debugging "nothing arrives". In order: (1) confirm IAM allows CreateLogGroup/CreateLogStream; (2) check the stream name isn't being reused across hosts; (3) enable SDK HTTP debug via 'http' => ['debug' => true] on the client; (4) confirm close() is actually invoked (destructors don't run in some FPM configurations with fastcgi_finish_request). aws logs describe-log-streams --log-group-name X --order-by LastEventTime is your fastest sanity check.

Extension points. Subclass CloudWatch and override createLogStream() to implement your own stream naming policy, or write() to add redaction. The constructor is small enough that a subclass with injected config is usually cleaner than a wrapper.

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.
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect