Structured Laravel Logging You Can Actually Query (No Datadog Required)
You don't need a $500/month observability platform to query your logs. Here's how I set up structured logging on a single VM that doesn't make me want to grep through walls.
Most single-VM Laravel apps I inherit have the same log setup: the default daily channel, a wall of unstructured text in storage/logs/laravel-*.log, and a developer who's been doing tail -f and hoping for the best. That's fine until something breaks at 2am and you're staring at a 400MB log file trying to correlate a user ID across five different request cycles.
You don't need Datadog. You don't need New Relic. You need your logs in a format you can query, and a lightweight stack to do it. Here's exactly what I run.
The Actual Problem
Unstructured logs are a search problem dressed up as a logging problem. When everything is a plain string, you can only find things you already know how to find. grep 'ERROR' laravel.log works until you want to answer "show me all failed jobs for user 4821 in the last hour that weren't retried." That's a query. You can't grep your way to it without a lot of pain.
Structured logging means every log entry is a JSON object with consistent fields — level, message, context, user_id, request_id, duration_ms, whatever matters to your app. Once your logs are JSON, they're queryable. And on a single VM, you can query them with tools that cost nothing and run in 50MB of RAM.
The stack I've settled on: Laravel writes JSON via Monolog, promtail ships lines to Loki, and Grafana sits in front for querying. Total resource footprint is small enough to run comfortably on a $20 DigitalOcean droplet alongside the app itself.
Setting Up Structured JSON Logging in Laravel
Laravel ships with Monolog under the hood, which already supports a JSON formatter. You just have to tell it to use one.
In config/logging.php, I define a custom channel:
'channels' => [
// ...
'structured' => [
'driver' => 'monolog',
'handler' => Monolog\Handler\StreamHandler::class,
'formatter' => Monolog\Formatter\JsonFormatter::class,
'with' => [
'stream' => storage_path('logs/structured.log'),
'level' => Monolog\Level::Debug,
],
],
],
Then set LOG_CHANNEL=structured in your .env and you're getting JSON lines. But raw Monolog JSON is a little bare — no request ID, no authenticated user, no app context. I fix that with a tap.
First, the tap class:
<?php
namespace App\Logging;
use Monolog\Processor\IntrospectionProcessor;
use Monolog\Processor\WebProcessor;
class AddRequestContext
{
public function __invoke($logger): void
{
$logger->pushProcessor(function (array $record): array {
$record['extra']['app'] = config('app.name');
$record['extra']['env'] = config('app.env');
$record['extra']['request_id'] = request()->header('X-Request-Id')
?? (string) str()->uuid();
if (auth()->check()) {
$record['extra']['user_id'] = auth()->id();
}
return $record;
});
// Adds file/line/class/function to each log entry
$logger->pushProcessor(new IntrospectionProcessor());
}
}
Then wire it into the channel:
'structured' => [
'driver' => 'monolog',
'handler' => Monolog\Handler\StreamHandler::class,
'formatter' => Monolog\Formatter\JsonFormatter::class,
'tap' => [App\Logging\AddRequestContext::class],
'with' => [
'stream' => storage_path('logs/structured.log'),
'level' => Monolog\Level::Debug,
],
],
Now every log line looks like this (pretty-printed for readability — on disk it's one line):
{
"message": "Order payment failed",
"context": {
"order_id": 18842,
"amount": 149.99,
"gateway_code": "insufficient_funds"
},
"level": 400,
"level_name": "ERROR",
"channel": "structured",
"datetime": "2025-01-14T03:22:11.843291+00:00",
"extra": {
"app": "MyApp",
"env": "production",
"request_id": "a3f9c021-...",
"user_id": 4821,
"file": "/var/www/app/Services/PaymentService.php",
"line": 94
}
}
That's a log entry you can actually do something with.
Shipping to Loki with Promtail
Loki is Grafana's log aggregation system. It's intentionally minimal — it indexes labels, not the full text, which keeps memory and storage low. On a single VM it's a very different beast than Elasticsearch.
Install Loki and Promtail. On Ubuntu:
# Download and install — check https://github.com/grafana/loki/releases for current version
curl -O -L "https://github.com/grafana/loki/releases/download/v3.3.2/loki-linux-amd64.zip"
curl -O -L "https://github.com/grafana/loki/releases/download/v3.3.2/promtail-linux-amd64.zip"
unzip '*.zip'
sudo mv loki-linux-amd64 /usr/local/bin/loki
sudo mv promtail-linux-amd64 /usr/local/bin/promtail
Loki config (/etc/loki/config.yaml) — the defaults are fine to start:
auth_enabled: false
server:
http_listen_port: 3100
ingester:
lifecycler:
ring:
kvstore:
store: inmemory
replication_factor: 1
schema_config:
configs:
- from: 2024-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
storage_config:
tsdb_shipper:
active_index_directory: /var/lib/loki/index
cache_location: /var/lib/loki/index_cache
filesystem:
directory: /var/lib/loki/chunks
limits_config:
reject_old_samples: true
reject_old_samples_max_age: 168h
Promtail config (/etc/promtail/config.yaml) — this is the piece that actually reads your log file and ships it:
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/promtail-positions.yaml
clients:
- url: http://localhost:3100/loki/api/v1/push
scrape_configs:
- job_name: laravel
static_configs:
- targets:
- localhost
labels:
job: laravel
app: myapp
env: production
__path__: /var/www/myapp/storage/logs/structured.log
pipeline_stages:
- json:
expressions:
level_name: level_name
user_id: extra.user_id
request_id: extra.request_id
- labels:
level_name:
user_id:
request_id:
The pipeline_stages section is where the value is. Promtail parses the JSON on ingest and promotes specific fields to Loki labels. That means in Grafana you can filter by label — {job="laravel", level_name="ERROR"} — without doing a full text search.
Set both up as systemd services, enable them, and they'll survive reboots without thinking about it.
Querying in Grafana
Install Grafana, add Loki as a data source pointing at http://localhost:3100, and you're done with setup.
LogQL — Loki's query language — takes about 20 minutes to get comfortable with. A few queries I actually use:
# All errors in the last hour
{job="laravel", level_name="ERROR"}
# Everything for a specific user
{job="laravel", user_id="4821"}
# Failed payment events, parsed from JSON
{job="laravel"} | json | context_gateway_code != ""
# Error rate over time (for a panel)
sum(rate({job="laravel", level_name="ERROR"}[5m]))
That last one gives you a graph of error rate over time, which I pin to a Grafana dashboard for every client project. First time a production deploy causes a spike, you'll feel very smug about having set this up.
The Gotchas That Will Bite You
Log rotation. If you're using logrotate to rotate structured.log, Promtail can lose its place. Set copytruncate in your logrotate config and Promtail will handle it cleanly. Or switch the Laravel channel to use stdout and pipe that through a service manager — more on that in a minute.
Queue workers. Your queue:work processes don't share a request lifecycle with HTTP requests, so auth()->check() won't do what you want. Pass user context explicitly:
Log::withContext(['user_id' => $this->order->user_id])->info('Processing order', [
'order_id' => $this->order->id,
]);
High-cardinality labels. I made the mistake early on of promoting request_id to a Loki label. Every request is a unique value, which tanks Loki's performance because it has to maintain a stream per label combination. Keep high-cardinality values in the JSON body and filter with | json | request_id = "..." instead of as a label. Loki docs call this out, but it's easy to miss.
Clock skew. If your VM's clock drifts, Loki will reject log entries outside its reject_old_samples_max_age window. Run chrony or systemd-timesyncd and make sure it's actually working. I spent an embarrassing amount of time debugging a quiet Promtail because the VM clock was 10 minutes behind.
The datetime field from Monolog. Loki uses its own ingestion timestamp by default. If you want Loki to use the actual log timestamp (so reingested logs show up at the right time), add a timestamp stage to your Promtail pipeline:
- json:
expressions:
log_time: datetime
- timestamp:
source: log_time
format: RFC3339Nano
When I'd Reach for This
This setup is my default for any single-VM Laravel app with meaningful traffic. It costs nothing, runs on the same box as the app, and gives me actual queryable logs. I put this on a healthcare scheduling app I maintain — nothing with PHI in the logs, but I needed to track appointment booking failures by provider ID. Two LogQL queries and I had a dashboard. No SaaS contract, no per-seat pricing, no data leaving the server.
I'd skip it if:
- You have multiple app servers and need centralized log aggregation across them. At that point Loki still works, but you're configuring remote Promtail agents and it's worth evaluating whether a managed solution is cheaper than your ops time.
- Your team is already on Datadog or Papertrail and it's working. Migrating working infrastructure for ideological reasons is a bad trade.
- The VM is genuinely tiny — 512MB RAM or less. Grafana alone wants ~200MB. On a micro instance I'd skip Grafana and just query Loki directly with
logcli.
For greenfield single-VM projects, there's no reason not to do this from day one. The Laravel side takes an hour. The Loki/Promtail/Grafana setup takes another hour if you're doing it for the first time. After that you have real observability with zero recurring cost.
Closing Thought
Structured logging isn't an observability platform feature — it's a discipline. Get the JSON right on the Laravel side and the tooling almost doesn't matter. I've run these logs through Loki, through jq on the command line, and once through a quick Python script to answer a one-off question — same log file, three different tools, no reformatting needed. That's what structured data buys you.
Need help shipping something like this? Get in touch.