spatie/flare-daemon
A long-running PHP daemon that accepts error reports, traces, and logs from your Laravel app over local HTTP and forwards them to Flare asynchronously, keeping Flare delivery off your request path with automatic fallback to direct delivery.
What you're actually adopting: a framework-agnostic, long-running PHP process (ReactPHP event loop) that listens on a local HTTP port, accepts Flare payloads — errors, traces, logs — buffers them in memory per API key and entity type, and forwards them to Flare ingress asynchronously. Your Laravel app POSTs to 127.0.0.1:8787 instead of ingress.flareapp.io. If the daemon is down, the Flare client falls back to direct delivery, so adopting it should never increase dropped reports — it only removes upstream latency from the request cycle.
Step 1 — confirm you already have it. The daemon is installed transitively by Flare's shared PHP client packages, so it is likely already present:
ls vendor/bin/flare-daemon # PHAR-backed Composer bin
php vendor/bin/flare-daemon --help
The bin is a bundled PHAR built from runtime/composer.json, so its ReactPHP runtime is isolated from your app's dependency graph. You will not see ReactPHP or a PSR-7 constraint appear in your composer.lock. That's deliberate, and it's the main reason this package is safe to add to an existing Laravel app.
Step 2 — start it once, by hand, before automating anything.
FLARE_DAEMON_LISTEN=127.0.0.1:8787 php vendor/bin/flare-daemon --verbose
Verify the two endpoints you'll end up depending on:
curl -s localhost:8787/health
curl -s localhost:8787/status # per-key/per-type buffer depth
Step 3 — point the Flare client at it. Installation is not activation: the README is explicit that "installed by default" and "enabled by default" are separate decisions. You must opt in explicitly in the client configuration (your config/flare.php / client env, depending on the Flare client version you run). Until you do, nothing changes and the daemon sits idle.
Step 4 — first real use case. Take the highest-volume, lowest-value traffic off the synchronous path first: logs. Error reports are rare and worth the round trip; log lines are not. Enable daemon transport for logging, leave errors on direct delivery for a week, and compare /status throughput against what the Flare dashboard receives. Once that's trusted, move errors and traces over.
Read these first, in this order: the configuration table in the README (there are only six env vars — read all of them), tests/test.sh (the reference end-to-end verification you should crib for your own smoke test), then loadtest/README.md for the expected throughput envelope (~18k reports/sec, p95 <11ms on an M-series Mac).
Local development — two processes, one script. Don't run the daemon under php artisan serve. Put both in composer.json and let the FLARE_COMPOSER_LOCK watcher handle the annoying part:
"scripts": {
"dev": [
"FLARE_COMPOSER_LOCK=composer.lock php vendor/bin/flare-daemon --verbose &",
"php artisan serve"
]
}
When you composer require something, the daemon notices the lockfile change and shuts down gracefully instead of holding a stale PHAR in memory. Restart it as part of your loop.
Docker Compose — the listen address is the whole trick. Inside a container, the default 127.0.0.1:8787 binds only to that container's loopback and is unreachable from the app container:
services:
flare-daemon:
image: ghcr.io/spatie/flare-daemon
environment:
FLARE_DAEMON_LISTEN: 0.0.0.0:8787
PHP_MEMORY_LIMIT: 128M
ports: ["8787:8787"] # optional; only needed if you want host access
app:
environment:
FLARE_DAEMON_URL: http://flare-daemon:8787
depends_on: [flare-daemon]
Production on a single host / VM — supervisor, not systemd-only. You want restart-on-crash plus a real SIGTERM on deploy (the daemon drains buffers on SIGINT/SIGTERM, so stopwaitsecs must exceed your drain time):
[program:flare-daemon]
command=php /var/www/app/vendor/bin/flare-daemon
environment=FLARE_DAEMON_LISTEN="127.0.0.1:8787",FLARE_COMPOSER_LOCK="/var/www/app/composer.lock"
autorestart=true
stopsignal=TERM
stopwaitsecs=30
stdout_logfile=/var/log/flare-daemon.log
Keep the listener on loopback. If the app and daemon are on different hosts, tunnel it — the local hop has no auth and no TLS.
Kubernetes — use the Helm chart, not a hand-rolled Deployment. The chart is a DaemonSet, one pod per node, with service.internalTrafficPolicy=Local, so app pods hit the daemon on their own node. Install it and point clients at the in-cluster service:
helm install flare-daemon oci://ghcr.io/spatie/charts/flare-daemon \
--version 1.2.3 --namespace flare-daemon --create-namespace
# clients: http://flare-daemon.flare-daemon.svc.cluster.local:8787
The node-local design is intentional: a node whose daemon is missing or wedged falls back to direct delivery rather than routing cross-node and adding latency.
Deploy ordering that doesn't lose data. Drain-then-stop means the old daemon finishes flushing before it exits — so stop the daemon after the app is drained, not before. Concretely: put the daemon in the same release unit as the app, send SIGTERM, wait out stopwaitsecs, then start the replacement. A SIGKILL on the daemon discards every buffered payload, and the client has no way to replay them because it already got a 2xx.
Verify the fallback actually works — this is the pattern people skip. Stop the daemon mid-traffic and confirm reports still land in Flare:
# with the daemon running and daemon transport enabled
kill $(pgrep -f flare-daemon)
php artisan tinker --execute="report(new Exception('fallback probe'));"
# expect: report appears in Flare, and app latency returns to direct-delivery levels
Then restart the daemon. If the fallback probe doesn't appear, your client configuration is wrong — you're not actually on the daemon transport, and everything else you're about to build is theatre.
Smoke test in CI/staging before trusting a release. Use the shipped script against a real API key; it exercises the buffering/flushing path rather than just the HTTP surface:
php vendor/bin/flare-daemon --verbose &
bash tests/test.sh "$FLARE_API_KEY" # add -u http://flare-daemon:8787 to target elsewhere
Instrument /status. The daemon logs a periodic summary of forwarded payloads, but /status is your scrapeable signal. Alert on "buffer depth non-zero and rising across several scrapes" — that's upstream back-pressure or a paused key, which is invisible in the Flare dashboard because the data has simply not arrived.
Buffers are in-memory only. There is no disk spool. A crash, an OOM kill, or a SIGKILL loses everything queued. Size PHP_MEMORY_LIMIT (Docker/Helm expose it; default 128M) against your sustained ingest rate, and treat FLARE_DAEMON_BUFFER_BYTES (256 KB default) as a safety net, not a latency knob — the flush cycle is ~1s and fires as soon as payloads are accepted, so the byte/time thresholds exist to catch the case where flushing has stalled, not to batch for efficiency.
127.0.0.1 in Docker is the single most common failure mode. The default is correct for a bare-metal/VM install and wrong for every container topology. A daemon that's "running fine" but receives nothing is almost always bound to the wrong interface. Check with docker exec flare-daemon curl -s localhost:8787/health then from the app container.
The fallback hides outages by design. Everything keeps working when the daemon is down — which means you can run for months with a silently broken daemon and degraded (direct) delivery, paying no cost except the latency you thought you'd removed. You must monitor the daemon independently. The same logic applies to Kubernetes: a missing node-local daemon degrades quietly.
Pause semantics are asymmetric, and they changed in 0.3.1. 403 no longer pauses delivery; 429 does, and rate-limit 429s pause for shorter than quota 429s. If you're on an older daemon against a newer client (or vice versa), reasoning about "why did we stop sending" gets confusing. Pin the daemon version and check CHANGELOG.md when ingest behaves oddly. Pause/resume is also only logged on state change as of 0.3.1, so a long pause produces one log line, not a stream of them — don't read silence as health.
Test payloads bypass the buffer entirely. They make a synchronous upstream request and return the upstream response to the caller. That's ideal for verifying credentials and connectivity, and useless for validating buffering, throughput, or the flush cycle. Likewise, --test mode accepts payloads and never forwards upstream — great for k6 run loadtest/loadtest.js, catastrophic if it ever reaches production. Put it behind an explicit env flag or don't put it in the image's default command at all.
Config caching and long-lived workers. Laravel caches configuration and queue workers hold it in memory. Changing the daemon URL or enabling daemon transport requires php artisan config:cache (or config:clear) and php artisan queue:restart. A worker that's been running since before the change will keep POSTing to the old address indefinitely — and will keep "succeeding" thanks to the fallback.
The upstream hop is the back-pressure point. FLARE_DAEMON_UPSTREAM_TIMEOUT_SECONDS defaults to 10s. The daemon is single-process; a slow or hanging upstream eats into the capacity you measured with the load test. If you point FLARE_DAEMON_UPSTREAM at a mock sink for testing (a legitimate extension point — it's env-only, no plugin API), expect different timing characteristics than production ingress.
Vendor bin is a compiled PHAR. Don't try to patch vendor/bin/flare-daemon or dig for ReactPHP classes in vendor/ — they aren't there in source form. For source work, use php src/daemon.php from a repo checkout, and bash build.sh to rebuild (downloads Box if absent). Debugging against a stale PHAR is a recurring time sink.
Version alignment is yours to manage. The daemon has one canonical version stream: Git tags → Packagist → PHAR → Docker (v-prefixed) → Helm (plain SemVer with a v-prefixed appVersion). Client packages depend on compatible daemon ranges but don't own the version. If you install Helm with --version 1.2.3 and the Composer bin resolves 0.3.x, you're running two different daemons. Pin both explicitly.
Debugging ladder, in order: --verbose (-v) for per-payload DEBUG lines → curl /status for buffer depth per key/type → daemon stdout for pause/resume state changes → verify binding with an in-container curl /health → only then suspect the client config. Start at the daemon, not at the Laravel side; the app will look perfectly healthy throughout.
PHP floor is 8.1 as of 0.3.0 (was 8.2), and the PHAR's embedded platform check is rebuilt per release — so an older daemon artifact on an 8.1 machine will refuse to start even though the source is compatible.
How can I help you explore Laravel packages today?