A Fluent Logging API for PHP
Logging that reads like an intention.
Select a level, attach structured context and decide when to emit a record. Phlogger provides a fluent logging API for PHP 8.3+ and the PSR-3 logger you already use.
use Phlogger\FluentLogger;
use Phlogger\Level;
$logger = new FluentLogger($psrBackend, minimumLevel: Level::Info);
$logger->atWarning()
->withCause($exception)
->atMostEvery(30)
->log('Payment failed for order {id}', ['id' => $orderId]);
Installation
Phlogger targets PHP 8.3 and later. Its only runtime package dependency is psr/log. Supply any PSR-3 backend: your application's logger, a test logger, or Psr\Log\NullLogger.
Install Phlogger from Packagist with Composer:
composer require roukmoute/phlogger
Phlogger is the logging API; the supplied backend handles storage, formatting and transport.
Quick start
Each at*() method starts a fluent record. Call log() to emit it. All eight PSR-3 levels are available: debug, info, notice, warning, error, critical, alert and emergency.
$logger->atInfo()
->with('user_id', $userId)
->log('User {user_id} signed in');
$logger->at(Level::Notice)
->withContext(['job' => 'daily-report', 'rows' => 120])
->log('Report generated');
$logger->atError()
->withCause($exception)
->log('Report generation failed');
log() passes the message template and context to the backend. Placeholder interpolation belongs to the backend. Use logf() when you explicitly want PHP sprintf() formatting.
$logger->atInfo()->logf('Processed %d rows in %.2f seconds', $rows, $seconds);
Context and exceptions
Use with() for one value and withContext() for a group. Logger context, fluent context and terminal context merge in that order; the most specific value wins.
$requestLogger = $logger->withContext([
'request_id' => $requestId,
'component' => 'http',
]);
$requestLogger->atInfo()
->with('component', 'checkout')
->log('Checkout started', ['component' => 'payment']);
// The backend receives component=payment and request_id=$requestId.
The logger and fluent builders are immutable. Adding context returns a new value, so the original logger retains its defaults. Contextual logger clones share the same rate-limit store.
withCause($throwable) stores the throwable under the PSR-3 exception key. It accepts both exceptions and PHP errors. withCause(null) sets that key to null, clearing an earlier cause. Context passed to the terminal log() call can override that key.
Arrays, objects, nulls and plain closures remain structured context. Phlogger does not stringify arbitrary values or run ordinary closures supplied as context.
Lazy logging
Wrap an expensive context value or formatting argument with LazyValue::of(). You can also pass a closure as the message. Deferred work runs only after Phlogger admits the record through its level filter, conditions and rate limits.
use Phlogger\LazyValue;
$logger->atDebug()
->with('summary', LazyValue::of(fn () => calculateSummary()))
->log('Summary: {summary}');
$logger->atDebug()
->log(fn () => buildDiagnosticMessage());
$logger->atInfo()
->every(100)
->logf('Queue size: %d', LazyValue::of(fn () => countQueue()));
A lazy wrapper is evaluated once per record and its result is passed to the backend. Resolution applies to top-level context values; wrappers nested inside arrays or objects are not traversed. An ordinary closure in context remains an ordinary closure.
The backend may still filter a record. PSR-3 has no API for checking a backend's enabled levels. Configure Phlogger's minimumLevel or filter to avoid deferred work at levels your application disables.
Keep deferred callbacks free of application side effects. Exceptions from callbacks and from the backend propagate to the caller.
Filtering and conditions
The minimum level defaults to Level::Debug. Raise it to exclude more verbose records. An optional filter closure receives the selected Level and returns a boolean; it further restricts logging.
$logger = new FluentLogger(
$psrBackend,
minimumLevel: Level::Info,
filter: fn (Level $level): bool => $level !== Level::Notice,
);
$logger->atWarning()
->when(fn () => $queue->isOverloaded())
->log('Queue is overloaded');
$logger->atInfo()
->when($shouldAudit)
->log('Audit event recorded');
Repeated when() conditions must all allow the record. Use isEnabled() to check level eligibility before separately doing expensive work; it does not consume rate-limit state or predict whether conditions and limits will admit the record.
if ($logger->atDebug()->isEnabled()) {
// Prefer LazyValue for work belonging to a single record.
}
Rate limiting
Control repetitive logging at the call site. Rate limits run before deferred message and context evaluation.
| Method | Behavior |
|---|---|
every(3) | Admit the first call, then calls 4, 7, 10 and so on. every(1) has no effect. |
first(5) | Admit only the first five eligible records for this site and policy. first(0) suppresses all records. |
atMostEvery(30) | Admit the first call, then require at least 30 seconds since the last admitted call. Zero disables this time limit. |
$logger->atWarning()
->atMostEvery(30)
->log('Remote service is unavailable');
$logger->atDebug()
->first(5)
->log('Initial sample', ['sample' => $sample]);
With several limits in a chain, every limit must allow the call. Interval counters and the time window reset together after admission. Records rejected by a level filter or condition do not consume quota.
every() requires a positive count; first() accepts zero or a positive count. atMostEvery() requires finite, non-negative seconds. Repeating the same limit method replaces that method's previous value.
Call sites and explicit keys
By default, state is identified by the terminal call's file and line, level and rate-limit policy, within the logger family. Different lines are independent. Two calls on one line with the same level and policy share state.
Use withRateLimitKey() to select a stable key, particularly in logging helpers. A key shares state only when the level and policy also match.
$logger->atWarning()
->withRateLimitKey('payment-provider-unavailable')
->atMostEvery(30)
->log('Payment provider unavailable');
Lifetime and memory
State is local to the logger family and the current PHP process. Contextual clones share it; separately constructed loggers and separate workers do not. These limits do not coordinate PHP-FPM workers or application instances.
The default store holds up to 1,024 rate-limit entries. Set rateLimitCapacity in the constructor to change it. The least recently used entry is evicted when the store fills; returning to an evicted key starts its quota again. Prefer stable keys over identifiers drawn from an unlimited set.
Time limits use a monotonic clock. An optional constructor clock closure returning seconds allows deterministic tests. Call resetRateLimits() to clear the shared store.
Admission consumes quota. A record admitted by Phlogger consumes its limit even if a deferred callback throws or the backend fails or declines to write it. Phlogger cannot confirm delivery through PSR-3.
PSR-3 integration
FluentLogger implements Psr\Log\LoggerInterface. Pass it to libraries expecting a PSR-3 logger, or use its normal level methods alongside fluent calls.
use Psr\Log\LoggerInterface;
function reportFailure(LoggerInterface $logger, Throwable $error): void
{
$logger->error('Operation failed', ['exception' => $error]);
}
reportFailure($logger, $exception);
$logger->info('User {id} signed in', ['id' => $userId]);
$logger->log('notice', 'Job completed', ['job' => $jobId]);
Direct PSR-3 methods use the same level filtering and default context. The terminal fluent log() receives a message; the logger's PSR-3 log() receives a level followed by a message. Unknown levels raise Psr\Log\InvalidArgumentException.
For PSR-3 compatibility, direct logger methods preserve all context values as supplied, including LazyValue objects. Deferred value resolution is a feature of fluent records.
Integration with Monolog
Phlogger is a fluent facade for any PSR-3 backend. Monolog is one option: it supplies handlers, processors and formatters while Phlogger controls fluent context, conditions and rate limits. Your application chooses to install Monolog; Phlogger's only runtime package dependency remains psr/log.
composer require "monolog/monolog:^3"
This example writes JSON records to standard output. The PsrLogMessageProcessor interpolates PSR-3 placeholders, while JsonFormatter formats the record and its structured context.
use Monolog\Formatter\JsonFormatter;
use Monolog\Handler\StreamHandler;
use Monolog\Logger as MonologLogger;
use Monolog\Processor\PsrLogMessageProcessor;
use Phlogger\FluentLogger;
use Phlogger\LazyValue;
use Phlogger\Level;
$handler = new StreamHandler('php://stdout', Monolog\Level::Info);
$handler->setFormatter(new JsonFormatter());
$backend = new MonologLogger('app');
$backend->pushHandler($handler);
$backend->pushProcessor(new PsrLogMessageProcessor());
$logger = new FluentLogger(
$backend,
filter: static fn (Level $level): bool => $backend->isHandling($level->value),
);
$requestLogger = $logger->withContext(['request_id' => 'request-123']);
$requestLogger->atInfo()
->log('Order {order_id} accepted', ['order_id' => 42]);
$error = new RuntimeException('Payment provider unavailable');
$requestLogger->atError()
->withCause($error)
->log('Payment failed for order {order_id}', ['order_id' => 42]);
The info record contains the message Order 42 accepted and retains the request and order identifiers in its context. The error record carries the throwable under exception, which Monolog's formatter can normalize.
Skip deferred work at rejected levels
The filter checks whether Monolog's configured pipeline currently accepts the selected level. With the info-level stream handler above, a debug record is rejected before Phlogger evaluates its lazy values:
$debugEvaluations = 0;
$requestLogger->atDebug()
->with('details', LazyValue::of(static function () use (&$debugEvaluations): string {
++$debugEvaluations;
return 'Diagnostic summary';
}))
->log('Diagnostics: {details}');
// $debugEvaluations remains 0.
Check the complete pipeline. isHandling() estimates whether a handler accepts a level; it does not guarantee delivery. Buffering or routing handlers can make additional decisions later. Use the configured logger's check rather than copying a final file handler's threshold: an outer handler may intentionally collect lower-level records. Phlogger's minimumLevel can restrict the pipeline further.
Record the application call site
Configure Monolog's introspection processor to skip Phlogger's wrapper frames and the inherited PSR methods such as info(). Attach it to the logger so it runs before buffering handlers; a processor behind FingersCrossedHandler sees the flush location instead of the original call.
$backend->pushProcessor(new Monolog\Processor\IntrospectionProcessor(
skipClassesPartials: ['Phlogger\\', 'Psr\\Log\\'],
));
The introspection example records distinct application file and line information even when records are buffered.
Run the complete example
The repository includes an executable Monolog example. In a development checkout, composer install installs Monolog as a development dependency for examples and integration tests:
composer install
php examples/monolog.php
Read the Monolog usage guide for configuring handlers and processors.
Symfony
Additional fluent service
Register Phlogger as an additional service in config/services.yaml, then inject FluentLogger into your application services:
services:
Phlogger\FluentLogger:
arguments:
$backend: '@logger'
The existing PSR logger binding stays available. For a Monolog channel declared in your application, use @monolog.logger.payments as the backend instead. MonologBundle continues to configure handlers, processors and routing.
The backend's level threshold is not discovered through PSR-3. Set $minimumLevel to your application's policy, or configure a factory with the Monolog readiness filter to skip deferred work at backend-disabled levels.
The runnable Symfony example uses the real dependency injection container. A second job's warning is suppressed; a third job emits after an explicit reset. The integration guide contains application configuration and channel recipes.
Make Phlogger the default @logger
Use this alternative definition in config/services.yaml so @logger, autowired LoggerInterface and FluentLogger resolve to the same Phlogger instance:
services:
Phlogger\FluentLogger:
decorates: logger
decoration_inner_name: phlogger.backend
arguments:
$backend: '@phlogger.backend'
Psr\Log\LoggerInterface: '@logger'
Replace the additional-service definition above with this one. Symfony keeps the original logger behind phlogger.backend. Referencing @logger as the decorator's backend would create a circular dependency.
Existing consumers typed as LoggerInterface keep their PSR methods such as info(). Inject FluentLogger when using atInfo() and the fluent API. Monolog still manages handlers, processors and its lifecycle; explicitly injected named channels keep their own loggers.
The default debug minimum forwards every PSR level. Configure $minimumLevel deliberately if you want filtering across all default-logger consumers. Backend-specific consumers can inject @phlogger.backend.
The default logger example compiles this service graph, autowires both types and writes PSR and fluent records through Monolog. Run php examples/symfony-default-logger.php from a development checkout.
Laravel
Add a scoped binding in AppServiceProvider::register(), then inject FluentLogger into your controllers, services or jobs:
use Illuminate\Contracts\Foundation\Application;
use Phlogger\FluentLogger;
use Psr\Log\LoggerInterface;
$this->app->scoped(
FluentLogger::class,
static fn (Application $app): FluentLogger => new FluentLogger(
$app->make(LoggerInterface::class),
),
);
This retains Laravel's logging manager, shared context and log events. For a configured channel, use $app->make('log')->channel('payments') as the backend; for a stack, use $app->make('log')->stack(['single', 'slack']). Continue writing through the Laravel logger rather than its unwrapped Monolog backend.
The factory defaults to Phlogger's debug minimum. Set minimumLevel in the factory if you need Phlogger to reject lazy computation early. Keep the fluent binding separate from LoggerInterface to preserve existing logging and avoid circular resolution.
The runnable Laravel example uses real components and explicitly renews scopes between two simulated jobs. It does not boot a full Laravel application or Octane server.
Choose the lifetime of worker quotas
Contextual clones share their parent's limiter state. A shared worker logger keeps quotas between jobs; a new instance or resetRateLimits() starts fresh quotas. A per-job reset makes a one-minute limit admit the first warning of every job.
Symfony
Services are shared by default. To clear quotas when Symfony resets services, including Messenger's normal between-message reset, add this tag to the additional-service variant:
services:
Phlogger\FluentLogger:
arguments:
$backend: '@logger'
tags:
- { name: kernel.reset, method: resetRateLimits }
For the default-logger decorator, add the same tag to its existing definition while retaining $backend: '@phlogger.backend'. Keep the tag absent for worker-wide quotas. Disabling framework service resets also disables this reset boundary. In a custom sequential loop, call the method between jobs yourself.
Laravel
A scoped() binding is renewed for each request or queue job, including Octane and queue workers. A singleton() binding retains worker-wide quotas. Resolve scoped consumers again in the next scope; a singleton that captures a scoped logger keeps the old instance and its state.
Separate quota and backend lifecycles. Resetting Phlogger does not clear Monolog buffers or Laravel shared context. Continue using the framework's normal backend lifecycle. Do not reset a shared logger family while concurrent work is using it.
Tests use real Symfony and Illuminate components to verify injection, channels, Laravel context and events, and shared versus renewed quotas. They do not boot full framework kernels or workers. Framework components are development dependencies only; Phlogger's sole production package dependency remains psr/log.
Benchmarks
Measure API overhead from a development checkout without a benchmark package:
composer install
composer benchmark -- --iterations=100000 --samples=5
php benchmarks/run.php --iterations=100000 --samples=5 --json
The runner compares direct and guarded PSR calls, enabled and disabled fluent statements, deferred values, reused entries and rate limits. It uses NullLogger, warm-up and repeated samples, excluding file and network I/O. JSON output includes raw measurements and the runtime configuration.
Times are per attempted statement. Peak temporary memory is not an allocation count, and manual guards have fewer capabilities than shared keyed quotas. These observations do not predict application throughput or impose CI timing thresholds. See the benchmark guide for comparison limits.
API reference
| Entry point | Purpose |
|---|---|
at(Level|string), eight at*() methods | Start an immutable fluent record at a PSR-3 level. |
with(string, mixed) | Attach one context value. |
withContext(array) | Merge context on a record, or derive a contextual logger. |
withCause(?Throwable) | Set the exception context value, including null. |
when(bool|Closure) | Require a condition before admitting a record. |
every(int), first(int), atMostEvery(float) | Limit calls by frequency, count or elapsed seconds. |
withRateLimitKey(string) | Choose the key for rate-limit state instead of using the call site. |
isEnabled() | Check level eligibility without consuming quota. |
log(string|Stringable|Closure, array) | Emit a template and structured context. A message closure returns a string or Stringable. |
logf(string, mixed ...) | Emit a message formatted with sprintf(). Arguments must resolve to scalar, null or Stringable values. |
LazyValue::of(Closure) | Defer a top-level context value or a formatting argument. |
resetRateLimits() | Clear the logger family's shared limiter store. |
Read the source and examples on GitHub. The API integrates with the PSR-3 specification.