Logger
Named channels routed to configurable sinks — rotation, compression, sampling, buffering, hash-chained audit logs, and alerting handlers, Monolog 3.x backed.
Logger
vortos-logger wires Monolog 3 as the PSR-3 LoggerInterface. No setup is required for basic usage — sensible defaults cover every environment. For production-grade setups you configure channels and the sinks they route to via VortosLoggingConfig.
Core concepts
If you have not worked with structured logging before, a few concepts matter before diving in.
What a logger actually is
A logger is a named pipe for log records. When you call $logger->error('Payment failed', ['id' => $paymentId]), you are producing a record that travels through a chain of handlers. Each handler decides where the record goes — a file, stderr, a Slack webhook, a Sentry project. Handlers can be stacked; a single record can go to multiple destinations.
Channels
A channel is just a name attached to a logger instance. $logger->info('...') written to the payment channel produces a record labelled channel=payment. In your log aggregator (Datadog, Loki, CloudWatch) you can filter channel=payment to see only payment-related logs, without sifting through cache hits and HTTP routing noise. Vortos creates one logger per channel and lets each one have its own minimum level and routing.
Sinks
A sink is a named destination — a file, a stream, syslog, or a custom handler service (OTLP, Kafka, SIEM). Each sink owns its own handler stack: formatter, sampling, buffering, rotation/compression, and (for Audit) hash-chaining. Channels route to one or more sinks. By default every channel gets its own same-named sink, but you can fan a channel out to additional sinks (e.g. ship security to both a local file and a SIEM shipper).
Log levels
Levels are a severity scale. Higher levels indicate more serious conditions. Most frameworks default to logging everything in development and only errors in production — Vortos does the same automatically.
| Level | Use when |
|---|---|
DEBUG | Development detail — query timings, state transitions, resolved values |
INFO | Normal expected events — request received, handler dispatched, payment processed |
NOTICE | Unusual but expected — config fallback used, optional service unavailable |
WARNING | Something might go wrong — deprecated method called, retry attempt |
ERROR | Failure that needs investigation — payment declined, external API returned 500 |
CRITICAL | System-level failure — message ended up in dead letter queue, DB unreachable |
ALERT | Someone must act now — data loss, security breach |
EMERGENCY | System completely unusable |
Structured context
Always pass dynamic values as the second argument — never in the message string.
// CORRECT — searchable, parseable by log aggregators
$logger->error('Payment failed', ['payment_id' => $id, 'amount' => $amount]);
// WRONG — the values are buried in text, not queryable
$logger->error("Payment {$id} for {$amount} failed");This is also a security rule. Vortos redacts sensitive context/extra keys, and also scans message text and values for common secret/PII patterns (JWTs, AWS keys, auth headers, card numbers, emails) before a record leaves the process — see Redaction.
Use static messages and put all user-controlled values in context:
$logger->info('Login failed', ['email' => $email]);Do not concatenate or interpolate user input into the message:
$logger->info('Login failed for ' . $email);Installation
composer require vortos/vortos-loggerPackage registration
use Vortos\Logger\DependencyInjection\LoggerPackage;
$packages = [
new LoggerPackage(), // order 10 — first, other packages inject LoggerInterface
new HttpPackage(),
new TracingPackage(),
// ...
];LoggerExtension requires kernel.project_dir and either kernel.log_path or a var/log directory under the project. Set these container parameters in your bootstrap if they aren't already:
$container->setParameter('kernel.env', $_ENV['APP_ENV'] ?? 'prod');
$container->setParameter('kernel.project_dir', __DIR__ . '/..');
$container->setParameter('kernel.log_path', __DIR__ . '/../var/log');Configuration
Create config/logging.php in your project root. Every setting has a sensible default — you only override what you need.
php vortos make:config logginguse Monolog\Level;
use Vortos\Logger\Config\LogChannel;
use Vortos\Logger\DependencyInjection\VortosLoggingConfig;
return static function (VortosLoggingConfig $config): void {
// Per-channel minimum level override
$config->channel(LogChannel::Query)->level(Level::Warning);
// Disable channels that are too noisy for your app
$config->channel(LogChannel::Cache)->disable();
// Configure a sink: rotation, sampling, custom path
$config->sink(LogChannel::Cache->value)
->toFile('cache.log')
->sample(100)
->rotation(maxFiles: 7, maxAgeDays: 14);
// Fan security logs out to a SIEM shipper as well as the local sink
$config->sink('siem')->customHandler('app.logging.siem_handler');
$config->channel(LogChannel::Security)->alsoRouteTo('siem');
// Inject trace_id into every log record (requires vortos-tracing)
$config->correlationId(true);
// Alerting — only triggered at or above the configured level
$config->sentry(dsn: $_ENV['SENTRY_DSN'] ?? '');
$config->slack(webhook: $_ENV['SLACK_WEBHOOK'] ?? '', minLevel: Level::Critical);
$config->email(to: 'ops@example.com', minLevel: Level::Error);
};Environment-specific overrides go in config/{env}/logging.php. For example, config/dev/logging.php is merged on top of the base config when kernel.env = dev.
Channels
Vortos defines nine built-in channels. Framework components write to their own channel — your application code writes to app.
| Channel | Constant | Written by | Default buffering |
|---|---|---|---|
app | LogChannel::App | Your code | Batched |
http | LogChannel::Http | HTTP middleware | Batched |
cqrs | LogChannel::Cqrs | CommandBus | Batched |
messaging | LogChannel::Messaging | EventBus, Kafka, Outbox | Batched |
cache | LogChannel::Cache | Cache decorators | Batched |
security | LogChannel::Security | Auth, rate limiting | Write-through |
audit | LogChannel::Audit | Authorization audit trail | Write-through + hash chain |
query | LogChannel::Query | DBAL, MongoDB | Batched |
tooling | LogChannel::Tooling | Make/migration/setup commands | Batched |
Security and Audit default to write-through — every record is written immediately, with zero loss on crash. Audit additionally hash-chains every record and enforces a 365-day file retention floor (see Audit hash chain).
Injecting a specific channel
LoggerInterface always resolves to the app channel. To inject a named channel, reference its service ID directly.
use Psr\Log\LoggerInterface;
use Vortos\Logger\Config\LogChannel;
$services->set(PaymentService::class)
->arg('$logger', service('vortos.logger.' . LogChannel::App->value));
// Or reference the app channel logger which is the same as LoggerInterface
$services->set(OrderController::class)
->arg('$logger', service(LoggerInterface::class));All channel loggers are registered as vortos.logger.{channel} — e.g. vortos.logger.security, vortos.logger.query, vortos.logger.audit.
Disabling a channel
When a channel is disabled, a NullHandler is registered for it — log calls still compile and run, but records are immediately discarded with zero I/O.
$config->channel(LogChannel::Cache)->disable();
$config->channel(LogChannel::Query)->disable();
// Or via the legacy helper, which silences multiple channels at once
$config->disableChannel(LogChannel::Cache, LogChannel::Query);
// Or by observability module — maps to the channel that module logs to
$config->disableModule(ObservabilityModule::Make, ObservabilityModule::Persistence);The App channel cannot be disabled — it is the last-resort channel for your application code and alerting handlers always attach to it.
Custom channels
Pass any string to channel()/sink() to register an application-defined channel — it gets its own same-named sink and default level/buffering just like a framework channel.
$config->channel('payments')->level(Level::Info);Sinks
A sink is configured via $config->sink($id), which returns a SinkBuilder. Calling sink($id) with the id of a framework channel (e.g. LogChannel::Cache->value) configures that channel's default sink. Any other id is a free-standing sink you can route channels to.
Destinations
// File — relative paths resolve under kernel.log_path (var/log)
$config->sink('app')->toFile('app.log');
// Stream — any PHP stream URI
$config->sink('app')->toStream('php://stderr');
// Syslog
$config->sink('security')->toSyslog('myapp');
// Custom handler — reference a DI service implementing HandlerInterface.
// The framework wraps it in the same sampling/buffering pipeline as any
// other sink. Use this for OTLP collectors, Kafka topics, SIEM shippers.
$config->sink('siem')->customHandler('app.logging.siem_handler');If no destination is configured, the env-driven default applies: a rotating file under var/log/{id}.log in dev, php://stderr in production.
Level and buffering
$config->sink('app')->level(Level::Warning);
// Every record written immediately — zero loss on crash. Default for
// Security/Audit sinks.
$config->sink('security')->writeThrough();
// Buffer in memory, flush every N seconds via FlushScheduler. Default for
// all other sinks (2s).
$config->sink('app')->batched(flushIntervalSeconds: 10);Rotation and compression
File sinks rotate daily by default: 14 files, 30-day max age, 1GB total size cap, gzip-compressed rotated files.
$config->sink('app')->rotation(
maxFiles: 7,
maxAgeDays: 14,
maxTotalSizeMb: 256,
compress: true,
);
// Disable rotation entirely
$config->sink('app')->rotation(enabled: false);RotatingFileHandler enforces maxFiles for files it rotates during the current process. maxAgeDays and maxTotalSizeMb — and maxFiles across process restarts — are enforced by running vortos:logs:prune on a schedule.
Sampling
// Only ~1/100 records reach the handler — use for high-volume, low-value channels.
$config->sink(LogChannel::Cache->value)->sample(100);Do not sample Security or Audit sinks — every record on those channels matters.
Routing a channel to multiple sinks
$config->sink('siem')->customHandler('app.logging.siem_handler');
// Add 'siem' alongside the channel's default sink
$config->channel(LogChannel::Security)->alsoRouteTo('siem');
// Or replace the default routing entirely
$config->channel(LogChannel::Security)->routeTo('siem');Dev vs production behaviour
Dev (kernel.env = dev) | Production | |
|---|---|---|
| Formatter | JsonFormatter (structured) | JsonFormatter (structured) |
| Default destination | file var/log/{sink}.log | php://stderr |
| Rotation | On by default — 14 files / 30 days / 1GB, gzip | On for any file sink (off by default for stream sinks) |
| Buffering | Batched (2s flush), Security/Audit write-through | Same |
| Min level | DEBUG | ERROR (framework channels), WARNING (app) |
Production output:
{"message":"Payment failed","context":{"payment_id":"pay-456","error":"Card declined"},"level":400,"level_name":"ERROR","channel":"app","datetime":"2026-05-07T14:32:01+00:00","extra":{"trace_id":"abc123"}}Buffering and the flush scheduler
Batched sinks collect records in memory via Monolog's BufferHandler and flush in a single I/O operation rather than one syscall per log statement. FlushScheduler guarantees buffered sinks are flushed within their configured interval regardless of process lifecycle, via three triggers:
- Request/command end —
FlushBootListenerflushes due sinks after every kernel request and console command. - Periodic — a
SIGALRM-driven check flushes sinks whose interval has elapsed, for long-running FrankenPHP workers and daemons. - Shutdown — a
register_shutdown_functionperforms a final flush so no buffered records are lost when a process exits.
// Default flush interval (seconds) for batched sinks that don't configure
// their own via SinkBuilder::batched(). Default: 2.
$config->flushInterval(5);The trade-off: if PHP crashes mid-request (fatal error, OOM kill) before a flush trigger fires, buffered records since the last flush are lost — bounded by the flush interval. Security and Audit sinks are write-through by default specifically to avoid this.
Audit hash chain and retention floor
The Audit channel's default sink hash-chains every record: each record's extra.record_hash is sha256(prev_hash + canonical_json(record)), where prev_hash is the previous record's hash (or a fixed genesis value for the first record). Tampering with or deleting a historical record breaks the chain for every subsequent record — vortos:logs:diagnose and your log aggregator can detect this by recomputing hashes.
{"message":"Permission denied","channel":"audit","extra":{"prev_hash":"3f2a...","record_hash":"9c1b..."}}The Audit sink also enforces a 365-day minimum file retention (maxAgeDays). Reducing it below the floor throws InvalidLoggingConfigException unless explicitly acknowledged:
$config->sink(LogChannel::Audit->value)
->rotation(maxAgeDays: 90)
->acknowledgeComplianceRisk();Hash chain state is in-memory by default
InMemoryHashChainState resets prev_hash to genesis on every process restart. For a continuous chain across restarts, implement HashChainStateInterface against persistent storage (e.g. Redis, a database row) and register it under vortos.logger.hash_chain_state.
Correlation ID — linking logs to traces
When vortos-tracing is active, every log record automatically gets a trace_id field in the extra context. This means you can look at a slow trace in Jaeger/Tempo and immediately jump to the exact logs that were produced during that request.
{"message":"Order created","channel":"app","extra":{"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736"}}
{"message":"Payment charged","channel":"app","extra":{"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736"}}
{"message":"Email queued","channel":"messaging","extra":{"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736"}}All three records share the same trace_id — filter on it and you see the complete story of that request across every channel.
This works transparently — no code changes needed. The CorrelationIdProcessor reads the current span ID from TracingInterface and injects it. When tracing is disabled (NoOpTracer), currentCorrelationId() returns null and the processor silently skips.
$config->correlationId(bool $enabled = true); // default: enabledRedaction
RedactionProcessor scrubs sensitive data from every record before it leaves the process — both by key (structured context/extra) and by pattern (free-text message and values):
- exact sensitive keys use a normalized
O(1)lookup table - custom keys add a wildcard regex fallback
- nested arrays are processed recursively up to a fixed depth
- message and value strings are scanned for JWTs, AWS access key IDs,
Bearer/Basicauth headers, card-number-like sequences, and email addresses - message strings additionally have
\r/\nescaped to reduce CRLF log injection
Default sensitive keys include passwords, tokens, authorization headers, API keys, private keys, email, phone, SSN, cookies, and client secrets.
{
"message": "Login failed",
"context": {
"email": "[REDACTED]",
"ip": "203.0.113.10"
}
}// Enable/disable redaction (enabled by default) and add custom keys
$config->redaction(true, keys: ['card_number', 'iban', 'national_id']);Redaction is not a replacement for safe logging style. Treat the PSR-3 message as a static event name and put all dynamic values in context.
Alerting handlers
Alerting handlers deliver log records above a threshold to external systems. They are added to every non-disabled channel above their configured minimum level.
Sentry
composer require sentry/sentry-php$config->sentry(dsn: $_ENV['SENTRY_DSN'] ?? '', minLevel: Level::Error);Records at ERROR and above are automatically captured as Sentry issues with full context and stack traces.
Slack
$config->slack(webhook: $_ENV['SLACK_WEBHOOK'] ?? '', minLevel: Level::Critical);Use Slack Incoming Webhooks. At CRITICAL and above a message is posted to the configured channel. Use sparingly — ERROR generates too much noise for Slack.
$config->email(to: 'ops@example.com', minLevel: Level::Error);Uses PHP's mail() function. In production, point PHP's sendmail_path at a proper relay (Postfix, SES). This is a synchronous, buffered-and-flushed call — use only at CRITICAL and above if latency matters.
Empty DSN/webhook strings are silently skipped
All alerting handlers check for an empty string before registering. $_ENV['SENTRY_DSN'] ?? '' is safe — if the env var is unset, the handler is simply not registered. No error is thrown.
failOnMissingIntegrations
By default, configuring sentry() without sentry/sentry-php installed throws at container compile time. Set $config->failOnMissingIntegrations(false) to downgrade this to a silent skip — useful for local prototypes.
Operations commands
vortos:logs:prune
Sweeps every rotating file sink's directory and enforces maxFiles, maxAgeDays, and maxTotalSizeMb — independent of RotatingFileHandler's in-process rotation, so a cron job keeps disk usage bounded even if the app never restarts.
php bin/console vortos:logs:prune
php bin/console vortos:logs:prune --dry-runvortos:logs:diagnose
Prints the fully-resolved channel → sink topology: routing, levels, buffer policy, sampling, hash-chain, and rotation settings for every sink. Use this to confirm what logging.php actually resolves to in a given environment.
php bin/console vortos:logs:diagnoseImplementation steps
1. Install and register
composer require vortos/vortos-loggerRegister LoggerPackage first in your bootstrap — other packages depend on LoggerInterface being available.
2. Set container parameters
$container->setParameter('kernel.env', $_ENV['APP_ENV'] ?? 'prod');
$container->setParameter('kernel.project_dir', __DIR__ . '/..');
$container->setParameter('kernel.log_path', __DIR__ . '/../var/log');3. Create the config file (optional)
php vortos make:config loggingEdit config/logging.php — start with the defaults, enable Sentry when you have a DSN.
4. Inject and use
use Psr\Log\LoggerInterface;
final class OrderService
{
public function __construct(private readonly LoggerInterface $logger) {}
public function place(Order $order): void
{
$this->logger->info('Order placed', ['order_id' => $order->getId()]);
}
}How to verify it's working
Diagnose the resolved pipeline:
php bin/console vortos:logs:diagnoseDev environment — watch the log file in real time:
tail -f var/log/app.logCheck rotation/retention:
ls -la var/log/
php bin/console vortos:logs:prune --dry-runCheck JSON format in production:
APP_ENV=prod php bin/console cache:clear 2>&1 | head -5
# Should see: {"message":"...","level_name":"INFO",...}Check trace_id is injected:
grep '"trace_id"' var/log/app.log | head -3Check alerting handlers are registered (Sentry):
# Trigger an ERROR-level log and check Sentry receives it
$logger->error('Test Sentry alert', ['test' => true]);Services registered
vortos.logger.formatter.json — JsonFormatter
vortos.logger.processor.introspection — adds file/line/class to every record
vortos.logger.processor.redaction — key- and pattern-based redaction
vortos.logger.processor.structured — ECS/OTel service fields
vortos.logger.processor.request_context — HTTP/user/tenant context
vortos.logger.processor.correlation_id — adds trace_id when TracingInterface is active
vortos.logger.processor.hash_chain — chained record hashes (Audit, if configured)
vortos.logger.hash_chain_state — InMemoryHashChainState (if any sink hash-chains)
vortos.logger.sink.{id}.handler — base handler for sink {id} (file/stream/syslog/null)
vortos.logger.sink.{id}.sampled — SamplingHandler wrapper (if sampled)
vortos.logger.sink.{id}.buffered — BufferHandler wrapper (if batched)
vortos.logger.{channel} — Logger for {channel} — App channel is public
LoggerInterface — alias → vortos.logger.app
monolog.logger — alias → vortos.logger.app (legacy)
FlushScheduler — coordinates periodic/shutdown/request-end flushes
LogPruneCommand (vortos:logs:prune) — enforces file-sink retention
LogDiagnoseCommand (vortos:logs:diagnose) — dumps the resolved topology