· laravel, syslog, centralized-logging
Laravel logs from multiple servers in one place, without a new package
laravel.log split across servers behind a load balancer? Centralize Laravel logs with the built-in syslog channel and rsyslog over TLS.
It happens to almost every Laravel app that grows past one server. You move sessions into the database or Redis, put a load balancer in front, and the multi-server setup works. Then you hit the last annoyance: laravel.log is now split across every server. Three servers on Forge behind a load balancer, and every debugging session starts with SSH into each one, tailing three files and guessing which server handled the request.
One file is the wrong goal. One place is the right one, and Laravel gets you most of the way there with a channel it has shipped for years.
Why a shared file doesn’t work
The first idea is usually a shared disk: mount NFS on every server and point the daily channel at it. It works in testing. In production, every PHP-FPM worker on every machine appends to one file over the network, and you get lock contention, blocked requests and lines from two servers spliced together mid-sentence. People who try it report exactly this, and even when it works you are still reading the result with grep.
The second idea is php artisan pail. Pail is excellent, and it shows you one server: the one you ran it on.
What you want is every server sending its lines somewhere central as they happen, each line labelled with the server it came from, so you can watch them live and search them later.
Step 1: log to syslog instead of a file
Every Linux server already runs a syslog daemon, and Laravel has a channel that writes to it. In .env:
LOG_CHANNEL=syslog
LOG_LEVEL=info
That is the whole application change. The channel is already in config/logging.php, and it does three useful things for free:
- It labels your lines. The syslog program name is your
APP_NAME, lower-cased and hyphenated, so lines fromMy Shoparrive taggedmy-shop, separate from nginx, cron and everything else on the box. - It keeps your context. Laravel still appends the context array as JSON at the end of each line, exactly as it does in
laravel.log. - It sends each log entry as one message, stack trace included. PHP’s
syslog()hands the daemon the whole entry in one call, and rsyslog escapes the line breaks inside it (as the text#012) instead of splitting it up.
If you want to keep the local file while you switch over, put daily and syslog together in a stack channel.
Optional: drop the duplicate timestamp. Laravel’s default formatter starts each line with [2026-10-03 09:14:02], and syslog adds its own timestamp too. To send the message without it:
// config/logging.php
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
'formatter' => Monolog\Formatter\LineFormatter::class,
'formatter_with' => [
'format' => "%channel%.%level_name%: %message% %context% %extra%",
'allowInlineLineBreaks' => true,
'ignoreEmptyContextAndExtra' => true,
'includeStacktraces' => true,
],
],
Step 2: raise rsyslog’s message limit
This is the step most guides leave out. rsyslog’s default maximum message size is 8 KB. A Laravel exception with a full stack trace easily runs past that, and rsyslog quietly cuts it off. The first frames survive, which is often enough to fool you into thinking it worked.
Raise the limit at the top of /etc/rsyslog.conf, before any module(load=...) line, because inputs read the setting when they load:
global(maxMessageSize="64k")
Step 3: forward to one place, over TLS, with a disk queue
Now add a destination that sends everything on. A file in /etc/rsyslog.d/ does it. The parts that matter, whichever service you send to:
- TLS. Syslog from a cloud server crosses the public internet. Plain UDP or TCP syslog can be read by anyone on the path.
- A disk-assisted queue. While the connection is down, lines wait on disk and are sent when it comes back, and the queue survives an rsyslog restart. Without it, a network blip or a deploy that restarts rsyslog loses lines.
- Full hostnames (
preserveFQDN="on"), soweb-1.example.comandweb-1.staging.example.comdon’t both arrive asweb-1. - Keepalives, so a dead connection is noticed in about a minute and a half, not an hour.
With SaneLog, the Sources page gives you this configuration with your endpoint already filled in. On Debian or Ubuntu:
sudo apt install rsyslog-gnutls
sudo nano /etc/rsyslog.d/60-sanelog.conf # paste the configuration
sudo systemctl restart rsyslog
logger -t demo "hello from $(hostname -f)"
The TLS configuration needs rsyslog 8.2108 or newer, which Ubuntu 22.04 and Debian 12 have. Within a few seconds the server shows up named after its hostname, waiting for you to approve it. Approving it trusts its address, so other servers behind the same NAT gateway are approved as they appear. Repeat on each server.
What you have now
Every server is a source. Your application’s lines carry the program my-shop, nginx’s carry nginx, cron’s carry CRON. In one live stream you can:
- Watch a deploy. Pick
my-shopandwarningand above, and leave the tab open while it rolls out. - Filter on your own fields. SaneLog moves the JSON at the end of a line into fields, so
Log::info('Order paid', ['order_id' => 88231])gives you anorder_idfield you can filter on with one click, across every server. - Read stack traces whole. The
#012escapes are turned back into line breaks on arrival, so a trace reads as it did inlaravel.log. - See what happened around a line. Open the error on
worker-2and show everything every server logged in that second. The 402 that nginx returned onweb-1for the same customer is three lines away. - Share a line. Every line has a permalink, so the thing you found at 2am goes into the incident thread as a link.
No Composer package, one line in .env, and one file per server.
The other routes, honestly
- An error tracker (Sentry, Flare, Bugsnag) groups exceptions and shows you the stack trace better than any log tool. It is not a log. The
infoline that explains why the exception happened and the nginx line that shows what the user saw are not in it. Most teams should run both. - Self-hosting Elasticsearch, OpenSearch, Loki or Graylog works and costs nothing until you count the hours. For a team of two to ten, it tends to become one more server nobody patches.
- CloudWatch Logs with the agent is reasonable if you are entirely on AWS. The console is where most people give up.
- SaneLog is what we built for exactly this: a small team with a few servers who wants every line in one place without running anything. It takes the
syslogchannel and rsyslog setup above as it is, over TLS, and gives you the live tail, the context around any line and alerts from day one. One flat price covers all your servers and your whole team, and every plan is free for 14 days with no card.
Two mistakes to avoid
Sending UDP syslog straight from PHP. Laravel’s stock config includes a channel built on Monolog’s UDP syslog handler. It sends every line of a multi-line message as a separate packet, so a stack trace arrives as dozens of unrelated events, and it has a hard size limit that cuts long messages. Let the daemon do the forwarding.
Shipping debug from production. LOG_LEVEL=debug across four servers is how a month’s log allowance disappears into query logs. Surprise logging bills almost always trace back to one or two log statements: debug lines in hot code paths, verbose HTTP tracing shipped to production by accident, request payloads dumped inside loops. Nobody notices until the bill arrives. Use info in production, switch to debug while you chase something, then switch back. With a flat-priced log service the risk is a full allowance rather than a surprise invoice, but the lesson is the same.
In short
LOG_CHANNEL=syslog.global(maxMessageSize="64k")at the top ofrsyslog.conf.- One forwarding file per server, over TLS, with a disk queue.
SaneLog’s Getting started guide has the exact configuration, and every plan is free for 14 days with no card.