betalabs/laravel-cloudwatch-logs
A Laravel package that sends your application logs to AWS CloudWatch Logs, offering a custom log driver with configurable log groups and streams. Lightweight setup and sensible defaults for centralizing Laravel logs in AWS.
Install and wire it up
composer require betalabs/laravel-cloudwatch-logs
The package is a Laravel logging driver, so the whole integration surface is config/logging.php plus your AWS credentials. In order:
composer.json extra.laravel.providers array to config/app.php. Open the provider first — it tells you the driver name it registers (Log::extend(...)) and which config keys it reads. That file is the real documentation.php artisan vendor:publish --provider=...). If not, everything lives in your logging.php channel array.aws/aws-sdk-php, so it picks up the standard chain: AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_DEFAULT_REGION, an IAM instance/task role, or ~/.aws/credentials. On ECS/EKS/Lambda, prefer the role and set nothing else.logs:CreateLogGroup, logs:CreateLogStream, logs:DescribeLogStreams, logs:PutLogEvents scoped to arn:aws:logs:<region>:<account>:log-group:<your-group>:*.// config/logging.php
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['single', 'cloudwatch'],
],
'cloudwatch' => [
'driver' => 'cloudwatch', // confirm the exact name in the service provider
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'group' => '/aws/laravel/' . env('APP_ENV'),
'stream' => env('APP_NAME') . '-' . gethostname(),
'level' => env('LOG_LEVEL', 'error'), // verify key names against the source
],
],
First use case: stop SSHing into boxes to read laravel.log. Point production LOG_CHANNEL at a stack that keeps the local file and mirrors to CloudWatch, then query it in Logs Insights:
fields @timestamp, message, context.user_id
| filter level >= 40
| sort @timestamp desc
| limit 50
That single change is usually the whole justification for the package. Everything past that is tuning.
Keep the local channel in the stack. CloudWatch writes are network calls; if AWS is having a bad day you still want storage/logs/laravel.log to exist. The stack driver is the cheap insurance.
One stream per host, or per date. CloudWatch stream sharding determines your throttle ceiling. gethostname() for a fixed fleet, or date('Y-m-d') for autoscaled containers so you never have thousands of writers hitting one stream. Never use a single constant stream name across a horizontally scaled app.
Split channels by intent, not just severity.
'cloudwatch-app' => ['driver' => 'cloudwatch', 'group' => '/aws/laravel/app', 'level' => 'info'],
'cloudwatch-errors' => ['driver' => 'cloudwatch', 'group' => '/aws/laravel/errors', 'level' => 'error'],
Then Log::channel('cloudwatch-errors') for the things you actually alert on, while the noisy info chatter goes elsewhere with a shorter retention. This keeps alerting cheap and Logs Insights queries fast.
Log structured context, not string soup. CloudWatch Insights only works if fields are queryable:
Log::error('Payment capture failed', [
'order_id' => $order->id,
'tenant_id' => $order->tenant_id,
'gateway' => 'stripe',
'code' => $e->getCode(),
]);
If the driver's default formatter flattens context into a string, add a Monolog JsonFormatter or a custom formatter via the channel's formatter key so context lands as JSON and Insights can filter context.tenant_id = 42.
Push a request/job correlation id. A Monolog processor that injects a UUID (or request_id from your load balancer header, or the queued job's id) is the difference between a usable log stream and a wall of text. In the package's handler stack, pushProcessor on the channel's Monolog logger — or a tap/custom channel factory — is the extension point.
Use it for queue workers and scheduled commands too. This is where distributed logging pays off most, because those processes are invisible otherwise. Set LOG_CHANNEL in the supervisor env, and make sure the worker's stream name includes the queue name so you can filter.
Keep local/test/dev off the network. In config/logging.php, only reference the cloudwatch channel in stack when app()->environment('production', 'staging'). Otherwise every php artisan test will attempt a real AWS call and either slow down or fail the suite. Or define a null driver in testing.
Provision the log group with IaC, not the app. Let the driver call CreateLogGroup, but own the retention policy in Terraform/CloudFormation (e.g. retention_in_days = 30). Application-created groups default to "never expire," which is the single most common way to run up an AWS bill.
Adoption risk is real. One star and zero dependents means you are effectively the maintainer. Read the service provider and handler source before you commit; pin an exact version in composer.json, and be prepared to fork. The listed release date is in the future relative to most package churn, so verify tags actually resolve with composer show betalabs/laravel-cloudwatch-logs. The pragmatic fallback is one of the mature Monolog handlers for CloudWatch, or a 40-line custom Log::extend wrapping CloudWatchLogsClient::putLogEvents — the feature surface here is genuinely small.
Sequence tokens and stream conflicts. Classic PutLogEvents failures you'll meet in the wild:
ResourceAlreadyExistsException — a race on stream creation at deploy (many containers starting simultaneously). Catch and retry the put; the stream exists, keep going.DataAlreadyAcceptedException / InvalidSequenceTokenException — happens when multiple writers share one stream. Fix by sharding streams per host/process rather than by adding retry logic.ResourceNotFoundException — the group wasn't created yet; usually the driver's job, but check IAM logs:CreateLogGroup if group is a new name.Throttling limits bite in production. Roughly 5 PutLogEvents requests per second per stream, 10,000 events per batch, 256 KB per event, ~1 MB per batch. A busy app logging info on every request will hit the per-stream limit. Mitigations: raise the channel's level to warning/error in prod, shard streams by host, and confirm whether the driver batches/buffers before sending. If it sends one HTTP request per log record, this package will be a latency problem — test it under ab/wrk before trusting it.
Synchronous network I/O lives inside your request. Monolog handlers run inline. If the driver doesn't buffer, every Log::error adds an AWS round trip (tens of ms, more on retry). Acceptable for errors, disastrous for debug-level logging. Measure it; if it hurts, wrap writes in a queue or use a handler with an internal buffer flushed at terminate.
Errors can be swallowed. Monolog tends to catch handler exceptions so logging failure doesn't kill the request — great for uptime, terrible for debugging, because you'll see nothing and assume logs "just aren't showing up." Add a quick sanity check to your deploy script:
php artisan tinker --execute="Log::channel('cloudwatch')->error('deploy probe ' . now());"
aws logs tail /aws/laravel/production --since 5m
If the probe doesn't appear, it's credentials/region/IAM — check with aws sts get-caller-identity from the same environment (same container, same role).
Long-running processes and credential expiry. Queue workers and Octane keep the AWS client alive for hours. SDK credentials from instance metadata are auto-refreshed, but static keys and cached clients may not behave. If you see auth failures only on workers older than ~6 hours, that's the culprit — don't cache the client across recycles.
Missing region or a wrong one. aws/aws-sdk-php throws a fairly cryptic "Missing required client configuration options: region" — but Monolog may swallow it. Set AWS_DEFAULT_REGION explicitly in the environment; don't rely on it being inferred.
CloudWatch is not a search engine. aws logs filter-log-events and Insights are slow and per-stream; they're great for incident forensics and terrible for dashboards. Set expectations: this is durable, centralized, cheap-ish storage for debugging, not metrics. Pair it with actual metrics/alerts elsewhere.
Cost surprises. Ingestion is priced per GB, storage per GB-month, and Insights scans are billed. At info level on a chatty app you can easily 10x your logging spend versus local files. Start at error, add retention, and expand levels only when you've measured the volume.
Extension points worth knowing. The driver is almost certainly a thin adapter over a Monolog handler. That means you can (a) swap the formatter, (b) pushProcessor for correlation ids, (c) subclass the handler to add buffering/flush-on-terminate, or (d) bypass the package entirely with Log::extend('cloudwatch', fn() => new Logger(new YourHandler(...))) in a small custom service provider. Option (d) is often the right answer for a package this small — you keep the config ergonomics and own the code path, which removes the maintenance-lottery risk entirely.
How can I help you explore Laravel packages today?