Search by

aporat / laravel-cloudwatch-logger

aporat

A Laravel logging driver for AWS CloudWatch Logs integration

Package info

github.com/aporat/laravel-cloudwatch-logger

pkg:composer/aporat/laravel-cloudwatch-logger

Fund package maintenance!

aporat

Statistics

Installs: 11 373

Dependents: 0

Suggesters: 0

Stars: 3

Open Issues: 0

v3.0.0 2026-09-19 23:57 UTC

This package is auto-updated.

Last update: 2026-09-19 23:57:45 UTC


README

Latest Stable Version Downloads Codecov Laravel Version GitHub Actions Workflow Status License

A Laravel logging driver for AWS CloudWatch Logs.

Features

  • A custom Monolog channel that writes to CloudWatch Logs.
  • Configurable AWS client, log group, stream, retention, batching and throttling.
  • String, class, instance and callable formatters.
  • Optional caching of group/stream existence, so a busy app isn't paying two extra AWS calls per process.
  • Optional failure suppression, so an unreachable CloudWatch can't turn a handled error into a 500.
  • Configuration is validated up front: a bad value fails with a message naming the key, not with an opaque AWS error on the first log line.

Requirements

  • PHP: ^8.4
  • Laravel: ^12.0 || ^13.0
  • phpnexus/cwh: ^3.2 — the handler options this package exposes (create_stream, rps_limit, a PSR-6 cache pool and a null retention) only exist from 3.2 onwards.

Installation

composer require aporat/laravel-cloudwatch-logger

The service provider is auto-discovered. It registers a cloudwatch logging channel from the package defaults, so this already works:

Log::channel('cloudwatch')->error('Something broke');

To customise it, publish the config:

php artisan vendor:publish --provider="Aporat\CloudWatchLogger\CloudWatchLoggerServiceProvider" --tag="config"

The published config/cloudwatch-logger.php is a complete channel definition. If you define a cloudwatch channel yourself in config/logging.php, yours wins and the package leaves it alone.

Defining channels

A channel is a normal Laravel custom driver entry:

// config/logging.php
use Aporat\CloudWatchLogger\CloudWatchLoggerFactory;

'channels' => [
    'cloudwatch' => [
        'driver' => 'custom',
        'via' => CloudWatchLoggerFactory::class,

        'aws' => [
            'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
            'version' => 'latest',
            'credentials' => [
                'key' => env('AWS_ACCESS_KEY_ID', ''),
                'secret' => env('AWS_SECRET_ACCESS_KEY', ''),
            ],
        ],

        'group' => env('CLOUDWATCH_LOG_GROUP_NAME', 'my-app-production'),
        'stream' => 'errors',
        'name' => 'my-app',
        'level' => 'error',
        'retention' => 14,
        'formatter' => '%channel%: %level_name%: %message% %context% %extra%',
    ],
],

Leave both credential values empty to use the AWS default credential chain (IAM task/instance role, environment variables, ~/.aws/credentials). The empty block is removed before the client is built, so the SDK doesn't reject it.

Options

Key Type Default Description
aws array — Passed to the CloudWatchLogsClient constructor. region and version are required.
group string — Log group. Letters, digits, _, -, /, ., #; max 512 chars.
stream string — Log stream within the group. No : or *; max 512 chars.
name string — Monolog channel name, used by %channel%.
level string|int|Level debug Minimum level. Accepts a Monolog\Level, a severity number, or a level name.
formatter string|class|instance|callable LineFormatter See Formatters.
replace_placeholders bool false Interpolate {placeholders} from the context, as Laravel's other channels do.
retention int|null 14 Days to retain events, or null to never expire. Must be one of CloudWatch's accepted periods. Applied only when this package creates the group.
batch_size int 10000 Events buffered before a PutLogEvents call. 1–10000.
rps_limit int 0 Self-throttle to this many requests per second; 0 disables.
tags array<string,string> [] Tags applied when the group is created.
bubble bool true Whether records continue to other handlers.
create_group bool true Create the log group if missing.
create_stream bool true Create the log stream if missing.
cache bool|string|class|pool false See Avoiding the per-process describe calls.
cache_ttl int 300 Lifetime, in seconds, of a cached "group/stream exists" marker.
suppress_failures bool false Drop the log line instead of throwing when CloudWatch is unreachable.

Every one of these also accepts the string form env() produces, so CLOUDWATCH_LOG_BATCH_SIZE=25 and 'batch_size' => 25 behave identically.

Choosing a batch size

batch_size buffers events in memory and sends them in one PutLogEvents call. The right value depends on how long the process lives:

  • Queue workers, Horizon, long-running CLI — keep it low (often 1). A logger is cached for the life of the process, so a half-full buffer sits in memory until the next log line arrives, which may be minutes later.
  • HTTP requests — a larger batch is fine; the handler flushes when the request ends.

CloudWatch throttles PutLogEvents per stream. When it does, the handler sleeps for a second and retries, which blocks the process. If a stream is busy enough to hit that, set rps_limit so the handler paces itself instead.

Avoiding the per-process describe calls

By default the handler issues a DescribeLogGroups and a DescribeLogStreams call on the first write of every process, for every channel, to find out whether it needs to create them. Set cache to remember the answer:

'cache' => true,        // the application's default cache store
'cache' => 'redis',     // a specific store from config/cache.php
'cache' => App\MyPool::class,   // any PSR-6 CacheItemPoolInterface
'cache' => $poolInstance,
'cache_ttl' => 300,

Entries are namespaced per log group, so two channels writing to a same-named stream in different groups don't collide. Cache failures degrade to a miss — the handler just asks CloudWatch again.

If the group and stream are provisioned outside the application (Terraform, CloudFormation), set create_group and create_stream to false instead and skip the lookups entirely.

Failure handling

A log write that can't reach CloudWatch throws. In a request path that logs its own errors, that turns a handled failure into a 500. Set 'suppress_failures' => true to wrap the handler in Monolog's WhatFailureGroupHandler, which drops the record instead.

For a local fallback rather than a silent drop, use a Laravel stack channel:

'app' => [
    'driver' => 'stack',
    'channels' => ['cloudwatch', 'single'],
    'ignore_exceptions' => true,
],

Formatters

formatter accepts four shapes:

// 1. A LineFormatter template.
'formatter' => '%channel%: %level_name%: %message% %context% %extra%',

// 2. A FormatterInterface class name, resolved through the container.
'formatter' => Monolog\Formatter\JsonFormatter::class,

// 3. An instance.
'formatter' => new Monolog\Formatter\JsonFormatter,

// 4. A callable receiving the channel config.
'formatter' => fn (array $config) => new Monolog\Formatter\JsonFormatter,

A string that looks like a class name but doesn't resolve to a FormatterInterface is rejected, rather than being silently used as a format template.

Configuration errors

Invalid configuration throws Aporat\CloudWatchLogger\Exceptions\IncompleteCloudWatchConfig while the channel is being built.

Note that Laravel's LogManager catches anything a channel factory throws and substitutes its emergency file logger (storage/logs/laravel.log). So a misconfigured channel does not raise at the call site — it stops reaching CloudWatch, and the reason is written to the emergency log. Worth checking there if a channel goes quiet after a config change.

Testing

composer test      # phpunit
composer lint      # pint + phpstan (level 8)
composer ci        # both, with coverage

Contributing

See CONTRIBUTING.md. Security issues: SECURITY.md.

License

MIT. See LICENSE.