log in
consulting hosting industries the daily tools about contact

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.