Vortos
Logger

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.

LevelUse when
DEBUGDevelopment detail — query timings, state transitions, resolved values
INFONormal expected events — request received, handler dispatched, payment processed
NOTICEUnusual but expected — config fallback used, optional service unavailable
WARNINGSomething might go wrong — deprecated method called, retry attempt
ERRORFailure that needs investigation — payment declined, external API returned 500
CRITICALSystem-level failure — message ended up in dead letter queue, DB unreachable
ALERTSomeone must act now — data loss, security breach
EMERGENCYSystem 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-logger

Package registration

bootstrap/app.php
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:

bootstrap/app.php
$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 logging
config/logging.php
use 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.

ChannelConstantWritten byDefault buffering
appLogChannel::AppYour codeBatched
httpLogChannel::HttpHTTP middlewareBatched
cqrsLogChannel::CqrsCommandBusBatched
messagingLogChannel::MessagingEventBus, Kafka, OutboxBatched
cacheLogChannel::CacheCache decoratorsBatched
securityLogChannel::SecurityAuth, rate limitingWrite-through
auditLogChannel::AuditAuthorization audit trailWrite-through + hash chain
queryLogChannel::QueryDBAL, MongoDBBatched
toolingLogChannel::ToolingMake/migration/setup commandsBatched

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.

config/services.php
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
FormatterJsonFormatter (structured)JsonFormatter (structured)
Default destinationfile var/log/{sink}.logphp://stderr
RotationOn by default — 14 files / 30 days / 1GB, gzipOn for any file sink (off by default for stream sinks)
BufferingBatched (2s flush), Security/Audit write-throughSame
Min levelDEBUGERROR (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:

  1. Request/command endFlushBootListener flushes due sinks after every kernel request and console command.
  2. Periodic — a SIGALRM-driven check flushes sinks whose interval has elapsed, for long-running FrankenPHP workers and daemons.
  3. Shutdown — a register_shutdown_function performs 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: enabled

Redaction

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/Basic auth headers, card-number-like sequences, and email addresses
  • message strings additionally have \r/\n escaped 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.

Email

$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-run

vortos: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:diagnose

Implementation steps

1. Install and register

composer require vortos/vortos-logger

Register LoggerPackage first in your bootstrap — other packages depend on LoggerInterface being available.

2. Set container parameters

bootstrap/app.php
$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 logging

Edit 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:diagnose

Dev environment — watch the log file in real time:

tail -f var/log/app.log

Check rotation/retention:

ls -la var/log/
php bin/console vortos:logs:prune --dry-run

Check 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 -3

Check 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

Further Reading

On this page