Application logging: the log you can actually debug from at 2am
The pattern repeats on every project we take over: something breaks in production, someone opens the log file, and the file contains either almost nothing or a hundred megabytes of "GET /api/items 200" lines that answer no question anyone has ever asked. Nothing breaks when logging fails, so it fails silently for years.
Logs exist to answer questions
Write each log line against a question it might have to answer later. "What did the customer see when the invoice failed?" "Did the webhook fire at all, or did it fire and get rejected?" "Was this request slow because of the database or because the vendor API hung?" If you cannot name the question a line answers, it is noise, and noise makes grep useless on the night you need it.
The lines that earn their place constantly:
- Business events with an actor and a subject: user registered, plan upgraded, invoice paid, export requested. Attach the ids, because the names repeat and the context does not.
- Decisions made by your code when it guesses or falls back: which provider handled the email, whether a cached value was stale, which branch of a messy permission rule fired.
- The tail of every external call that matters: vendor, endpoint, status, duration. When Stripe has a bad day, you want the timeline, not the memory of one.
- Exceptions with context. In Laravel, wrap the message with the relevant ids via
report()context orLog::error(..., ['order_id' => ...]): a stack trace without the data it was operating on still leaves you guessing.
Structured beats free-text
A log line that reads like prose to a human reads like soup to a machine. When the incident tool joins, filters, or aggregates by a field, that field needs a stable name and type. JSON logs with a level, an event name, and a payload do that; a string with fields separated by pipes invites a custom parser nobody maintains.
Laravel's context helpers keep this from becoming ceremony in every call site:
Log::withContext(['request_id' => $requestId]);
Log::info('invoice.retry_scheduled', [
'invoice_id' => $invoice->id,
'attempt' => 3,
'next_attempt_at' => $invoice->next_attempt_at->toIso8601String(),
]);
One event name per thing, stable across releases, snake_case. Free-text messages change every time someone edits the sentence, and a dashboard built on the old spelling silently goes blind.
Correlation ids turn scattered lines into a story
A single customer action usually touches several processes: the HTTP request, a queued job, a webhook out. Without a shared id, the log reads as three unrelated events. Generate or accept a request id at the edge, push it into the logging context, and make the queue worker propagate it into job payloads:
Context::add('request_id', $request->header('X-Request-Id') ?? (string) Str::uuid());
Now a search for one id returns the whole story in order, which is the difference between five minutes and an evening during an incident.
Levels are a budget, not a checkbox
info, warning, error stop meaning anything fast if everything logs at the same level. A workable split: error means a human should look today; warning means the system recovered and the count matters; info is business events; debug is for the details you enable temporarily. The other hard rule: secrets and personal data stay out of logs entirely. Tokens, passwords, card data, and full personal addresses in a log file are a GDPR problem and a breach-scoping problem, and they persist for as long as the retention policy does.
Retention and rotation are part of the design
Logs grow until a disk fills, and a full disk takes the application down with it. Rotate and compress on a schedule, cap the retention window, and ship anything worth alerting on to a separate place before the local file gets truncated. A log that outlived the disk you needed to read it from is the last irony of a 2am incident.