Run Laravel on more than one box and your logs scatter. A request fails, and you are SSHing into three containers grepping storage/logs/laravel.log, hoping the failure landed on the instance you guessed. Tailing a single file in real time with Laravel Pail is great on one machine, and the local Telescope, Debugbar, and Pulse trio is great in development — but none of them answer "show me every ERROR across the fleet in the last hour." This guide wires Laravel into Grafana Loki so logs from every instance land in one queryable, alertable store. We will emit structured JSON, ship it with Grafana Alloy, and alert on error rate.
Loki is the cost-effective, self-hosted half of the picture: it indexes labels, not full text, so it stays cheap at volume. It pairs naturally with the metrics-and-traces side covered in the Laravel production observability guide.
Configure a structured JSON log channel in Laravel#
Loki works best when each log line is a JSON object instead of a formatted string. With JSON, Loki's | json parser pulls out fields like level_name and context, and you query on those fields instead of regex-matching raw text. In a containerised app you also want logs on stderr so the container runtime captures them — never a file inside the container that vanishes on redeploy.
Add a json channel to config/logging.php using Monolog's StreamHandler pointed at php://stderr, formatted with JsonFormatter:
'channels' => [
// ... your existing channels
'json' => [
'driver' => 'monolog',
'handler' => \Monolog\Handler\StreamHandler::class,
'with' => [
'stream' => 'php://stderr',
],
'formatter' => \Monolog\Formatter\JsonFormatter::class,
// Interpolates {placeholders} in messages into the JSON output
'processors' => [\Monolog\Processor\PsrLogMessageProcessor::class],
],
],
Then point the app at it in .env:
LOG_CHANNEL=json
JsonFormatter emits a flat object per line with message, level, level_name, channel, datetime, context, and extra keys. The level_name field ("ERROR", "WARNING", and so on) is what you will filter on in Loki later. If you cannot replace the whole channel — say you want to keep the default stack but reformat it — use a Monolog tap class instead, which mutates the handlers on an existing channel rather than defining a new one.
Add a request ID to every log line#
Structured logs are only half the win. The other half is correlation: when one request writes five log lines across a controller, a job, and a notification, you want all five tagged with the same ID so you can pull the whole story with one query. Laravel's Context facade is built for exactly this — anything you add to the context is automatically attached to every log entry written during that request, and it is even carried into queued jobs dispatched from the request.
Create a middleware that stamps a UUID onto the context:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class AddRequestId
{
public function handle(Request $request, Closure $next): Response
{
// Reuse an upstream ID from your load balancer if it sent one
$requestId = $request->header('X-Request-Id') ?? (string) Str::uuid();
Context::add('request_id', $requestId);
return $next($request);
}
}
Register it in bootstrap/app.php so it runs on every web request:
->withMiddleware(function (Middleware $middleware) {
$middleware->append(\App\Http\Middleware\AddRequestId::class);
})
Now every JSON log line for that request carries "context":{"request_id":"..."}. Because Context is dehydrated into queued jobs, a log line written by a job an hour later still shows the request ID that triggered it — a detail that pays for itself the first time you debug an async failure.
Run Loki and Grafana with Docker Compose#
With the app emitting JSON to stderr, you need somewhere for it to go. Stand up Loki (the log store) and Grafana (the UI) with Docker Compose. Loki's image ships with a sensible single-binary default config, so you do not need a config file to get started — pin the version, expose the ports, and give it a volume so data survives restarts.
services:
loki:
image: grafana/loki:3.7.2
command: -config.file=/etc/loki/local-config.yaml
ports:
- "3100:3100"
volumes:
- loki-data:/loki
grafana:
image: grafana/grafana:latest # pin to a specific release in production
ports:
- "3000:3000"
environment:
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
depends_on:
- loki
volumes:
loki-data:
Bring it up with docker compose up -d, then open Grafana at http://localhost:3000. Add Loki as a data source pointing at http://loki:3100. If you are already containerising Laravel for orchestration — see Dockerising your Laravel app from Dockerfile to running pod — this stack slots in alongside your existing Compose or Kubernetes manifests.
Ship container stdout to Loki with Grafana Alloy#
Loki does not pull logs; something has to push them. For years that something was Promtail, but Promtail reached end-of-life on March 2, 2026 and receives no further updates. Its replacement is Grafana Alloy, an OpenTelemetry Collector distribution that reads container stdout straight off the Docker socket and forwards it to Loki — no log driver to install and no coupling to your app code.
Create config.alloy:
discovery.docker "containers" {
host = "unix:///var/run/docker.sock"
}
discovery.relabel "containers" {
targets = []
// Turn the Docker container name into a "container" label
rule {
source_labels = ["__meta_docker_container_name"]
regex = "/(.*)"
target_label = "container"
}
}
loki.source.docker "containers" {
host = "unix:///var/run/docker.sock"
targets = discovery.docker.containers.targets
forward_to = [loki.write.local.receiver]
relabel_rules = discovery.relabel.containers.rules
}
loki.write "local" {
endpoint {
url = "http://loki:3100/loki/api/v1/push"
}
}
Then add Alloy to the Compose file, mounting both the config and the Docker socket so it can discover containers:
alloy:
image: grafana/alloy:v1.10.0
command:
- run
- /etc/alloy/config.alloy
- --server.http.listen-addr=0.0.0.0:12345
volumes:
- ./config.alloy:/etc/alloy/config.alloy:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
depends_on:
- loki
After docker compose up -d, Alloy discovers every running container, labels each stream with its container name, and pushes the lines to Loki. Migrating an existing Promtail setup? Grafana ships a converter — alloy convert --source-format=promtail turns your old promtail.yaml into Alloy syntax in one command.
Query logs and build a Grafana dashboard#
Logs are flowing; now make them useful. Grafana's Explore view is where you iterate on LogQL queries before pinning them to a dashboard. Start by selecting the container, then pipe through the json parser so the structured fields become filterable.
Show everything from the app container:
{container="my-app"} | json
Filter to errors and above using the level_name field that JsonFormatter wrote:
{container="my-app"} | json | level_name=~"ERROR|CRITICAL|ALERT|EMERGENCY"
Trace a single request end to end — Loki's | json parser flattens context.request_id into a context_request_id field:
{container="my-app"} | json | context_request_id="3f9b1c2a-..."
To build a dashboard, add a Logs panel backed by the error query above, and a Time series panel using sum(count_over_time({container="my-app"} | json | level_name="ERROR" [5m])) to chart error volume over time. Save it and you have a single pane for the whole fleet.
Alert on error rate from Grafana#
A dashboard you have to watch is not monitoring. The point of centralising logs is to be told when something breaks. Grafana's unified alerting can evaluate a LogQL query on a schedule and fire to Slack, PagerDuty, or email when a threshold trips — closing the loop from log line to notification.
Create a new alert rule with a Loki query that counts errors over a window:
sum(count_over_time({container="my-app"} | json | level_name=~"ERROR|CRITICAL|ALERT|EMERGENCY" [5m]))
Add a threshold expression that fires when the count climbs above your tolerance — IS ABOVE 10, say — set the evaluation interval to one minute, and attach a contact point. If you prefer alerting-as-code over the UI, Loki's built-in ruler reads the same LogQL from a rules YAML file and pushes to Alertmanager, so the rule lives in version control next to the rest of your infrastructure.
Gotchas and Edge Cases#
Never make request_id a Loki label. Loki indexes labels, and high-cardinality labels (a unique value per request) will blow up its index and wreck performance. Keep correlation IDs inside the JSON line and query them with | json | context_request_id=.... Labels are for low-cardinality dimensions like container, env, or level only. The same discipline that keeps monitoring quota from being wasted on malicious 404s applies here: be deliberate about what you index.
Loki is logs, not error tracking. Loki tells you how many errors and lets you read them; it does not group exceptions, dedupe them, or give you stack-trace-level grouping with release tracking. For that you still want something like self-hosted Sentry. The two are complementary — Loki for the full log stream, Sentry for exception triage.
Watch retention. The default Loki config keeps data indefinitely on disk. In production, configure the limits_config retention period and a compactor, or your loki-data volume grows without bound.
Timestamps must be strings on the push API. If you ever push to /loki/api/v1/push directly rather than through Alloy, the timestamp has to be sent as a string, not a number — a numeric value returns a 400. Alloy handles this for you, which is one more reason not to hand-roll the shipper.
Wrapping Up#
You now have structured JSON flowing from every Laravel instance into Loki, correlated by request ID, queryable in Grafana, and alerting on error rate. The whole stack is self-hosted, so it scales with your volume rather than your invoice. Pin every image to a specific release, set a Loki retention policy, and add env and app labels in Alloy so you can slice across multiple services.
From here, fill in the other two pillars of observability — metrics and traces — with the complete guide to production observability in Laravel, and add Kubernetes readiness and liveness probes so the platform restarts unhealthy instances before they fill your new error dashboard.
FAQ#
How do I send Laravel logs to Grafana Loki?
Configure Laravel to emit structured JSON logs to stderr using a Monolog channel with JsonFormatter, then run Grafana Alloy alongside your containers. Alloy reads each container's stdout off the Docker socket and pushes the lines to Loki's /loki/api/v1/push endpoint. You never point Laravel at Loki directly — the app just logs to stdout, and the collector ships it.
Should Laravel logs be JSON formatted for Loki?
Yes. Loki indexes labels and treats the log body as text, so plain formatted strings force you into brittle regex queries. JSON lets Loki's | json parser extract fields like level_name, channel, and your context values, so you can filter by log level or request ID precisely. JSON also keeps multi-line messages and stack traces as a single log entry rather than fragmenting them.
What is the difference between Loki and the ELK stack?
Loki indexes only a small set of labels and stores the log body compressed, where Elasticsearch (the E in ELK) full-text indexes every field. That makes Loki far cheaper to run and operate at volume, at the cost of slower arbitrary full-text search. If you mostly query by known dimensions like service, level, and time window, Loki is lighter; if you need rich full-text analytics across every field, ELK still has the edge.
How do I add a request ID to every Laravel log line?
Use the Context facade in a middleware: call Context::add('request_id', (string) Str::uuid()) on each incoming request. Laravel automatically appends context data to every log entry written during that request, and dehydrates it into any queued jobs the request dispatches. The ID then appears under the context key of your JSON logs, where Loki can query it as context_request_id.
Can I alert on Laravel error rate in Grafana?
Yes. Create a Grafana alert rule backed by a Loki query such as sum(count_over_time({container="my-app"} | json | level_name="ERROR" [5m])), then set a threshold that fires when the count exceeds your tolerance and attach a contact point like Slack or PagerDuty. Grafana evaluates the LogQL on a schedule and notifies you when error volume spikes, so you find out from the alert rather than from a customer.