Search by

PHP client for Lumber: end-to-end encrypted cloud logging, with a PSR-3 logger, a Monolog handler and a Laravel log channel

v1.0.0 2026-11-09 17:28 UTC

This package is auto-updated.

Last update: 2026-11-09 17:35:01 UTC


README

PHP client for Lumber: cloud logging with end-to-end encryption. Every log is encrypted with AES-256-GCM before it leaves your server, so Lumber's servers store only ciphertext plus each log's level (kept readable for filtering and push alerts). You read your logs in the Lumber app, which decrypts them on your device.

It's a PSR-3 logger, with a Monolog handler and a Laravel log channel. Its only dependencies are PHP's curl and OpenSSL extensions and psr/log, and logging never makes a visitor wait on the network.

Installation

composer require infinitetoken/lumber

PHP 8.2 or later.

Quick start

use InfiniteToken\Lumber\Lumber;

$lumber = new Lumber((string) getenv('LUMBER_API_KEY'));

$lumber->info('Server started', ['port' => 8080]);
$lumber->warning('Rate limit approaching', ['request_id' => 'req-123', 'user_id' => 456]);
$lumber->error('Payment failed', ['trace_id' => 'trace-abc', 'order_id' => 789, 'exception' => $e]);

That's all: the logs are sent when the request (or script) ends. In Laravel, skip this and set up the log channel.

Getting your API key

Create an app in the Lumber app and it takes you straight to adding an API key. The key is shown only once, so copy it right away. Any device holding the app's key can add more from the app's Secrets screen, one per deployment if you like.

An API key is two values joined by a dot: a token, then the app's encryption key. The client sends only the token to Lumber and encrypts every log with the rest, so the encryption key never leaves your server. Keep the API key in an environment variable or your secrets manager, never in source control.

How logs are sent

Most PHP runs one request at a time, with nothing left running in the background between requests, so the client can't send from a background thread the way Lumber's Node, Python and Go clients do. Instead it holds each request's logs, already encrypted, and sends them as the request ends:

  • PHP-FPM (nginx, Caddy, Apache with proxy_fcgi, most hosting), LiteSpeed and FrankenPHP: the response goes to the visitor first, and then the logs are sent, so nobody waits on Lumber. The PHP worker stays busy while they send, about a tenth of a second a log.
  • Apache's mod_php and php -S have no way to finish a response early, so the visitor's page finishes loading once the logs are sent.
  • A command-line script sends what it holds as it exits.

Logs are sent one at a time in the order they were logged, over one connection kept open between them. Sending stops after 5 seconds (flushTimeout), and anything left is dropped with a warning; raise it if a request logs more than a few dozen lines. Up to 1,000 logs are held (maxBuffer); one more sends them there and then.

Long-running processes (queue workers, Octane, RoadRunner) serve many requests or jobs from one process and never reach the end of a request, so they send at the end of each job or request instead: the Laravel integration does it for queue workers and Octane, the Monolog handler for Symfony, and anywhere else you call flush(). See Long-running workers.

Laravel

Laravel finds the package by itself. Add your API key to .env, and lumber to the channels the stack channel writes to:

LUMBER_API_KEY=your-api-key
LOG_CHANNEL=stack
LOG_STACK=single,lumber

Everything your app logs now goes to Lumber as well as storage/logs/laravel.log:

use Illuminate\Support\Facades\Log;

Log::info('Order placed', ['order_id' => 789, 'request_id' => $request->header('X-Request-Id')]);
Log::channel('lumber')->warning('Sent to Lumber only');

Records are sent at the matching level (NOTICE is INFO, and CRITICAL, ALERT and EMERGENCY are FATAL), {placeholders} in messages are filled from the context, and an exception Laravel reports arrives with its trace. What you add with Laravel's Context goes into each log's metadata, with trace_id and request_id as the log's own fields. Each log's context carries your app's environment (APP_ENV).

Logs are sent after the response has gone to the visitor, after each queued job, and after each of Octane's requests, tasks and ticks.

Settings, all optional but the key:

.env Default
LUMBER_API_KEY Your app's API key
LUMBER_LEVEL debug The lowest level the channel sends
LUMBER_ENVIRONMENT APP_ENV Sent as env in every log's context; empty sends none
LUMBER_SYNC false Send each log as it's logged (see Serverless)
LUMBER_AUTO_CONTEXT true Send the process id and hostname
LUMBER_TIMEOUT 10 Seconds to wait for Lumber to answer one log
LUMBER_FLUSH_TIMEOUT 5 Seconds sending may take as a request or job ends
LUMBER_MAX_BUFFER 1000 Logs held before they're sent there and then

php artisan vendor:publish --tag=lumber-config copies them to config/lumber.php. To change the channel itself, define lumber in config/logging.php ('driver' => 'custom', 'via' => InfiniteToken\Lumber\Laravel\LumberChannel::class, with Laravel's usual level, bubble, replace_placeholders and name). The client is in the container as InfiniteToken\Lumber\Lumber.

Laravel 12 or later. In an app whose config/logging.php predates LOG_STACK, add 'lumber' to the stack channel's channels.

Monolog

LumberHandler sends Monolog's records (Monolog 3), so any code that logs through Monolog sends to Lumber:

use InfiniteToken\Lumber\Lumber;
use InfiniteToken\Lumber\Monolog\LumberHandler;
use Monolog\Level;
use Monolog\Logger;

$lumber = new Lumber((string) getenv('LUMBER_API_KEY'));
$logger = new Logger('app');
$logger->pushHandler(new LumberHandler($lumber, Level::Info));

$logger->error('Payment failed', ['order_id' => 789, 'exception' => $e]);

A record's context and extra (what processors add) are the log's data, as below, with the context winning, and its channel goes into the log's context. Add Monolog's PsrLogMessageProcessor to fill {placeholders}. new LumberHandler($apiKey) makes its own client, if you'd rather not share one.

In Symfony, register the client and handler as services in config/services.yaml:

services:
    # ...
    InfiniteToken\Lumber\Lumber:
        arguments:
            $apiKey: '%env(LUMBER_API_KEY)%'
            $environment: '%kernel.environment%'
    InfiniteToken\Lumber\Monolog\LumberHandler:
        arguments:
            $lumber: '@InfiniteToken\Lumber\Lumber'
            $level: info

and give the handler to MonologBundle under monolog: in config/packages/monolog.yaml:

monolog:
    handlers:
        lumber:
            type: service
            id: InfiniteToken\Lumber\Monolog\LumberHandler

Symfony resets its loggers between Messenger's messages, and the handler sends what's held when it's reset.

Log methods

Method Level
debug($message, $context) DEBUG
info(...), notice(...) INFO
warning(...), warn(...) WARN
error(...) ERROR
critical(...), alert(...), emergency(...), fatal(...) FATAL. None of them exit
log($level, $message, $context) A PSR-3 level (Psr\Log\LogLevel), a Level, or 'warn' or 'fatal'
flush(?float $timeout) Sends what's held now, waiting flushTimeout seconds at most; false if time ran out first
close(?float $timeout) Sends what's held and stops; later logs are dropped

Logging never throws, but for an unknown level, which PSR-3 asks a logger to refuse.

Data

The context array is the log's data:

$lumber->info('User signed in', [
    'request_id' => 'req-abc',             // the log's request ID
    'trace_id' => 'trace-xyz',             // the log's trace ID
    'context' => ['region' => 'us-east'],  // merged into its context
    'metadata' => ['plan' => 'pro'],       // merged into its metadata
    'user_id' => 123,                      // any other key goes into metadata
    'exception' => $e,                     // a Throwable: its class and message, and its trace
]);

requestId and traceId, the API's own names, work too. An explicit metadata key wins over a colliding key. A Throwable under exception goes into metadata as exception (RuntimeException: Card declined) and trace (PHP's own account of it, with the exceptions that led to it). {placeholders} in the message are filled from the context, as PSR-3 describes, and the values are still sent. Values JSON has no form for are sent as text: a date as ISO 8601, an enum as its value, a JsonSerializable as what it serializes to, another object as its public properties, and a NaN as null. Everything, message included, is encrypted.

Options

$lumber = new Lumber(
    (string) getenv('LUMBER_API_KEY'),
    environment: 'production', // sent as `env` in every log's context
    autoContext: true,         // send the process id and hostname with every log
    caller: false,             // also send the file, line and function that logged
    callerDepth: 0,            // frames to skip for `caller`, one per function of yours wrapped around Lumber
    sync: false,               // send each log as it's logged, waiting for Lumber, rather than as the request ends
    afterResponse: true,       // finish the response before sending (PHP-FPM, LiteSpeed, FrankenPHP)
    timeout: 10.0,             // seconds to wait for Lumber to answer one log
    flushTimeout: 5.0,         // seconds sending may take at a flush, the end of the request included
    maxBuffer: 1000,           // logs held before a new one sends them there and then
    warnings: fn (string $message) => $other->warning($message), // in place of PHP's error log
);

Finishing the response early writes a PHP session first (session_write_close()), or the visitor's next request would wait on the logs for the session's lock. Turn afterResponse off if something after your script's own end still needs to send output.

Delivery

A network error or a 5xx counts as a failed attempt, and so does no answer within timeout: each log gets 5, waiting 1, 2, 4, then 8 seconds between them, and is then dropped with a warning. A log Lumber refuses (a 4xx, such as an invalid API key) is dropped at once with a warning, since sending it again won't help. When your team has sent its plan's logs for the month, Lumber answers 429: the client warns once and drops logs until the month turns.

Every request starts afresh, so a request that finds Lumber unreachable, or at the monthly limit, leaves a note in the temp directory (named by a hash of your token), and the requests after it skip sending until it's over rather than each finding out for itself. While Lumber can't be reached, they hold off for 1 second, then 2, 4 and so on up to 30, until a log gets through. A Lumber outage costs your servers little, and never your visitors.

Warnings go to PHP's error log (error_log()), never through a logger, where they'd be sent back to Lumber, unless you pass warnings.

Long-running workers

A process that serves many requests or jobs sends at the end of each:

  • Laravel: queue workers (queue:work, Horizon) send after each job, and Octane after each request, task and tick, with nothing to set up.
  • Symfony: Messenger's workers reset loggers between messages, and the Monolog handler sends then.
  • RoadRunner, Swoole, FrankenPHP's worker mode, or your own loop: call flush() after each response or job:
while ($request = $worker->waitRequest()) {
    $worker->respond(handle($request));
    $lumber->flush(); // after the response, so nobody waits on it
}

Without a flush(), logs still go once 1,000 are held or the process exits, which for a worker could be hours. A forked process (pcntl) starts with an empty buffer and its own connection; the parent sends what it held before the fork.

Serverless

AWS Lambda (Bref, Laravel Vapor) freezes the process as soon as the response is sent, and Google Cloud Run slows it to a crawl unless CPU is always allocated, so logs held for after the response may not go until the next request, or at all. Use sync: true (LUMBER_SYNC=true in Laravel): each log is sent as it's logged, and the request waits for Lumber to answer, about a tenth of a second a log.

Development

composer install
composer test && composer analyse && composer lint

tests/contract holds Lumber-Kit's contract at the version in tests/contract/VERSION: a JSON Schema of the wire format and known answers for API keys, encryption, payloads and delivery, which Lumber's Node, Python and Go clients are tested against too. composer contract -- 0.8.0 moves it to a new kit version. The tests start PHP's built-in web server as a stand-in for Lumber's API.

License

MIT. See LICENSE.