PHP & Laravel Logs
zharp/logs sends logs and exceptions from PHP apps — a Laravel log channel in two lines of .env, a Monolog handler for Symfony, or calls of its own in plain PHP. No dependencies; PHP 7.4 and up.
Install
composer require zharp/logs
API key
- In the dashboard, open Integrations & API and choose Main API keys.
- Click Create API key, give it a name, and tick Ingest only. That key can send data but has no access to the REST API, so it's safe in a server's environment — and it's available on every plan.
- Click Create key and copy the key. It's shown only once.


Laravel
Nothing to register — Laravel discovers the package. Add two lines to .env:
ZHARP_API_KEY=your-ingest-only-key LOG_STACK=single,zharp
That's all. Everything Laravel logs now goes to Zharp as well as your log file — including every exception Laravel reports, with its class and stack. Logs are filed under your APP_NAME and tagged with your APP_ENV, unless you set ZHARP_SERVICE and ZHARP_ENV. Check it works:
php artisan zharp:test
Sent. Open Logs in Zharp and pick the service "checkout-api".
On Laravel 10 and older, add zharp to the stack in config/logging.php instead of setting LOG_STACK:
// config/logging.php — Laravel 10 and older
'stack' => [
'driver' => 'stack',
'channels' => ['single', 'zharp'],
],Then log as you always have:
use Illuminate\Support\Facades\Log;
Log::info('User signed in', ['user_id' => $user->id]);
Log::warning('Slow network detected', ['latency_ms' => 3200]);
try {
$gateway->charge($order);
} catch (Throwable $e) {
report($e); // sent with its class and stack
}- Uncaught exceptions and fatal errors are Laravel's to handle: it reports them through the log, so they reach Zharp through the channel too.
- Logs are sent once the response is out, and after every queued job and every Octane request, since those processes don't end in between.
ZHARP_LEVELsets the lowest level the channel sends (debugby default).php artisan vendor:publish --tag=zharp-configgives youconfig/zharp.phpto set it all in code.
Plain PHP
Call Zharp::init() once, as early as possible — at the top of your front controller or bootstrap file:
use Zharp\Logs\Zharp;
Zharp::init([
'api_key' => 'YOUR_INGEST_ONLY_KEY',
'service' => 'checkout-api',
'environment' => 'production',
'app_version' => '1.4.2',
]);Or set the environment and call Zharp::init() with nothing:
ZHARP_API_KEY=your-ingest-only-key ZHARP_SERVICE=checkout-api # optional — defaults to the name in your composer.json ZHARP_ENV=production # optional — no default
You can mix the two: what you pass is used, and anything you leave out is read from the environment. In a script or command it prints one line to stderr; under a web server it prints nothing, since it starts on every request.
[Zharp] Logging active — service "checkout-api", environment "production"
With no key anywhere it starts nothing and never throws. 'quiet' => true silences the startup line; ZHARP_DISABLED=true turns the SDK off.
Logging
Zharp::debug('Cache miss', ['key' => 'user:42']);
Zharp::info('Request received', ['path' => '/checkout']);
Zharp::warn('Rate limit approaching', ['remaining' => 5]);
Zharp::error('Stripe webhook failed', ['event_id' => 'evt_123']);
Zharp::fatal('Database connection lost');
// Any level — PSR-3 names too — and optionally another service name for this one log:
Zharp::log('warning', 'Charge declined', ['amount' => 5000], 'payments');The array is optional and is kept with the log. The PSR-3 level names — warning, notice, critical, alert, emergency — map to Zharp's five levels.
Exceptions
try {
$gateway->charge($order);
} catch (Throwable $e) {
Zharp::error('Payment failed', ['order_id' => $order->id, 'exception' => $e]);
Zharp::captureException($e); // the exception alone
Zharp::error($e); // the same
}An exception — as the message, or under 'exception' in the array, where PSR-3 puts it — brings its class and full stack, previous exceptions included. The message reads RuntimeException: Card declined when the exception is the message.
Zharp::setContext(['user_id' => $user->id]); // added to every log's context Zharp::resetSessionId(); // e.g. per job in a long-running worker
Uncaught exceptions and fatal errors
In plain PHP, from the moment init() succeeds, an uncaught exception is sent as a fatal log with its stack and type: "uncaughtException", then handled as before — by the exception handler you had set, or as PHP does (reported, exit code 255). A fatal error that ends the script without an exception — out of memory, a parse error in an include — is sent too, with type: "fatalError". Turn both off with 'capture_exceptions' => false.
Monolog and Symfony
Zharp\Logs\Monolog\ZharpHandler works with Monolog 2 and 3. Each record goes with its channel, context and extra, at the matching level; an exception under 'exception' in the context brings its stack.
use Zharp\Logs\Monolog\ZharpHandler; use Zharp\Logs\Zharp; Zharp::init(); $logger->pushHandler(new ZharpHandler()); // or new ZharpHandler(Level::Warning) for WARNING and up
Symfony
# config/services.yaml
services:
Zharp\Logs\Monolog\ZharpHandler: ~
# config/packages/monolog.yaml
monolog:
handlers:
zharp:
type: service
id: Zharp\Logs\Monolog\ZharpHandler// public/index.php and bin/console, before the kernel boots \Zharp\Logs\Zharp::init(); // ZHARP_* from the environment
When logs are sent
PHP has no background threads, so logs wait in memory and are sent when the request ends — never in the middle of it.
| Where it runs | When logs are sent |
|---|---|
| PHP-FPM, LiteSpeed | When the request ends, after the response has gone — visitors never wait on Zharp |
php -S, artisan serve | When the request ends; these can't finish a response early, so the request waits for the send |
| Laravel queue workers, Octane | After every job and every request |
| Scripts, commands, daemons | Every 5 seconds (flush_interval), at once for error and fatal, and when the script ends |
Zharp::flush(); // send what's queued now, waiting at most 5 seconds Zharp::close(); // send it all and stop; logs after this are dropped until init() again Zharp::isActive(); // whether init() succeeded and logs are being sent
Sending what's left takes at most 5 seconds, and at most 2 when a script crashes. If Zharp can't be reached in that time, what's left is dropped rather than holding the process.
Options
| init() option | Environment variable | Default |
|---|---|---|
api_key | ZHARP_API_KEY | — (nothing starts without one) |
service | ZHARP_SERVICE, then OTEL_SERVICE_NAME | The name in your composer.json (Laravel: APP_NAME), else unknown-service |
environment | ZHARP_ENV | None (Laravel: APP_ENV) |
app_version | ZHARP_VERSION | None |
source | ZHARP_SOURCE | None |
endpoint | ZHARP_ENDPOINT | https://api.zharp.io/api/v1 |
capture_exceptions | — | On in plain PHP; off in Laravel, which handles them itself |
flush_interval | — | 5 seconds, for long-running processes |
max_batch_size | — | 50 |
quiet | — | Off — on prints nothing at startup |
See your logs
Open Logs in the dashboard sidebar and pick your service in the service dropdown. Click a line to see its context and stack.

To be told when something shows up, add a log monitor.
What a log carries
{
"service": "checkout-api",
"level": "error",
"message": "Card declined",
"timestamp": "2026-10-26T10:00:43.512Z",
"environment": "production",
"version": "1.4.2",
"metadata": {
"name": "RuntimeException",
"stack": "RuntimeException: Card declined in /var/www/app/Services/Payments.php:42\nStack trace:\n#0 …",
"exception": { "name": "RuntimeException", "message": "RuntimeException: Card declined", "stack": "…" },
"channel": "production",
"sessionId": "a3f8c2d1-…",
"environment": "production",
"context": {
"hostname": "web-1",
"phpVersion": "8.3.12",
"platform": "Linux",
"sapi": "fpm-fcgi",
"pid": "4182",
"method": "POST",
"path": "/checkout",
"appVersion": "1.4.2"
}
}
}environment,versionandsourceare top-level tags, sent when set. See Unified service tagging.- Your own fields sit at the top of
metadata, next to an exception'snameandstack, the Monologchannel, and thesessionId— one per request or script run. - Where it ran goes under
context:hostname,phpVersion,platform,sapiandpid, and for a web request itsmethodandpath— never the query string, which can carry tokens. - Requests go to
/agent/logswith the key in anX-API-KEYheader andX-Zharp-SDK: php-logs/0.1.0. A failure on the network or a 5xx is retried, within the time limit; a 4xx is not.
The source is at github.com/zharp-io/zharp-php-lib.