Structured logging for the Scafera framework. Implements PSR-3 with a zero-dependency StreamLogger that writes JSON Lines to var/log/{environment}.log.
Provides: Structured logging (PSR-3) for Scafera — a zero-dependency
StreamLoggerthat writes JSON Lines tovar/log/{env}.log. Uncaught framework exceptions are auto-logged; application code writes its own entries.Depends on: A Scafera host project (kernel + architecture package) with a writable
var/log/directory on reliable local storage. Application code injectsPsr\Log\LoggerInterface— notStreamLoggerdirectly.Extension points: None by design —
StreamLoggeris not extensible. You can aliasPsr\Log\LoggerInterfaceto a custom implementation, but doing so breaks thelogs:*CLI commands, which depend on the JSON Lines format.Not responsible for: Log rotation (delegated to
logrotate/OS) · best-effort logging (RuntimeExceptionon write failure, per ADR-049) · application log routing or multi-destination (use Monolog manually) · runtime validation of theeventkey (build-time only viaEventContextValidator) · Symfony's default error logging (disabled via compiler pass to avoid duplicate entries).
This is a capability package. It adds optional structured logging to a Scafera project. It does not define folder structure or architectural rules — those belong to architecture packages.
Scafera treats logging the same way it treats every other capability — explicit, minimal, and boundary-safe. Application log calls are written by the developer at the call site — no userland listeners, middleware, or automatic logging. Framework-level errors (uncaught exceptions, console failures) are captured automatically by the package as infrastructure. The logger writes structured JSON Lines with a required event field for categorization — required by build-time validation (scafera validate), not enforced at runtime.
composer require scafera/logThe bundle is auto-discovered via Scafera's symfony-bundle type detection. No manual registration needed.
- PHP 8.4+
scafera/kernel^1.0psr/log^3.0
Inject Psr\Log\LoggerInterface via constructor — it resolves to StreamLogger automatically:
use Psr\Log\LoggerInterface;
final class OrderService
{
public function __construct(
private readonly LoggerInterface $logger,
) {}
public function place(Order $order): void
{
// ... business logic ...
$this->logger->info('Order placed', [
'event' => 'order.created',
'orderId' => $order->getId(),
]);
}
}Every log call should include an event key in the context array. Events use lowercase dot notation (domain.action):
$this->logger->info('User signed in', ['event' => 'auth.login', 'userId' => 42]);
$this->logger->error('Payment failed', ['event' => 'payment.failed', 'orderId' => 1]);
$this->logger->warning('Slow query', ['event' => 'db.slow_query', 'duration_ms' => 1250]);The event key is extracted from the context array and promoted to a top-level JSON field — it does not appear inside context. The remaining context is written under context, which is omitted entirely when empty after extraction. This makes log entries greppable and filterable by the CLI commands.
Each line is a self-contained JSON object:
{"timestamp":"2026-04-10T16:22:40.501+00:00","level":"info","message":"Order placed","event":"order.created","ip":"192.168.1.42","context":{"orderId":1}}Fields: timestamp (RFC3339 with milliseconds), level (PSR-3), message, event (present only when the context includes an event key), ip (client IP address, present only during HTTP requests), context (remaining context after event extraction, omitted if empty). Client IP is logged automatically during HTTP requests. To disable, override the StreamLogger service definition and omit the RequestStack argument.
The context array is sanitized to produce valid JSON. Scalars and arrays pass through, \Throwable instances (under the exception key per PSR-3 convention) are expanded to structured data, and non-serializable values are reduced to a safe representation. The exact serialization rules are an implementation detail and may evolve, but the output is always valid JSON.
Example of \Throwable serialization:
$this->logger->error('Payment failed', [
'event' => 'payment.failed',
'exception' => $e,
]);{"timestamp":"...","level":"error","message":"Payment failed","event":"payment.failed","context":{"exception":{"class":"App\\Exception\\PaymentFailedException","message":"Card declined","code":0,"file":"/app/src/Service/PaymentService.php","line":42}}}Logging failures throw \RuntimeException. If the log directory doesn't exist, permissions are wrong, or the underlying disk cannot accept the write, the error is visible immediately — no silent failures, no fallback channel. The underlying OS error message is captured via a temporary error handler and folded into the exception message, so the exception text includes the real reason (e.g. No space left on device, Permission denied).
This is a conscious trade-off, documented in ADR-049: Scafera treats logging as part of the system's feedback loop, not a best-effort side channel. A successful request that was not logged is unobservable, and in Scafera's model an unobservable success is worse than a visible failure. Projects that need best-effort logging (log loss acceptable to preserve availability) should use a different PSR-3 implementation.
The logger does not validate the event key at runtime. It writes whatever context it receives. Structured logging (consistent event key, lowercase dot notation) is guaranteed only when EventContextValidator is run via scafera validate.
Because write failures propagate as exceptions, storage choice matters:
- Logs must be written to reliable local storage. Local disk, local tmpfs, or a mounted volume backed by local block storage.
- NFS and other network filesystems are not recommended as a log sink. Network storage introduces failure modes (remote unavailability, soft-mount timeouts, partial writes) that will surface as
RuntimeExceptionand affect request handling. If you mountvar/log/over NFS, you accept that transient network issues will cause request failures. - Rotation is delegated to the operating system.
scafera/logdoes not implement in-process rotation — the hot write path is intentionally simple. Uselogrotate(or an equivalent) configured to rotate on size or date.StreamLoggeropens the log file fresh on every write viafile_put_contents, so post-rotation writes go to the new file without needing a reload signal.
When scafera/log is installed, uncaught exceptions are automatically logged to the same log file alongside application entries. This is framework infrastructure — the log package takes ownership of error visibility.
HTTP exceptions are logged with event framework.http.error:
- 4xx client errors (404, 403, etc.) are logged at
warninglevel — these are client mistakes, not system failures - 5xx server errors and unhandled exceptions are logged at
errorlevel
{"timestamp":"...","level":"warning","message":"No route found for \"GET /nonexistent\"","event":"framework.http.error","context":{"exception":{...},"method":"GET","path":"/nonexistent","status":404}}Console exceptions are logged with event framework.console.error at error level, including the command name and exit code.
The CLI commands work naturally with framework entries — logs:errors shows framework 500s, logs:filter framework.http.error isolates framework entries, and logs:stats counts them as a distinct event.
Symfony's built-in error logging is disabled via a compiler pass to prevent duplicate entries. Symfony's error response handling (the error page in dev, the error controller in prod) continues to work normally.
The EventContextValidator runs during scafera validate and checks:
- Every logger call in
src/includes an'event' =>key in the context - Event values match the format
domain.action(lowercase dot notation:/^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/)
The validator uses PHP's tokenizer (\PhpToken::tokenize, not an AST parser). It handles single-line and multi-line logger calls, inspects only the top level of the context array (nested 'event' keys are ignored), and format-checks event values only when they are string literals — dynamic values like Event::CREATED are accepted as present. Context built via a variable ($logger->info($msg, $context)), method return, or array spread cannot be inspected and is silently skipped.
All commands are available via vendor/bin/scafera:
# Show latest log entries (default 50)
vendor/bin/scafera logs:recent
# Show latest 10 entries
vendor/bin/scafera logs:recent --limit=10
# Show latest entries filtered by level
vendor/bin/scafera logs:recent --level=error
# Show only application logs (excludes framework.* events)
vendor/bin/scafera logs:recent --scope=app
# Show only framework logs
vendor/bin/scafera logs:recent --scope=framework
# Operational summary — errors/warnings from the last 24 hours (by timestamp), top events, recent failures
vendor/bin/scafera logs:status
# Show entries with severity >= error (error, critical, alert, emergency)
vendor/bin/scafera logs:errors
# Aggregate counts grouped by event, with level column
vendor/bin/scafera logs:stats
# Group by event and level separately
vendor/bin/scafera logs:stats --by-level
# Filter by event name
vendor/bin/scafera logs:filter order.created
# Filter by level
vendor/bin/scafera logs:filter --level=warning
# Search text in messages or context
vendor/bin/scafera logs:filter --search="timeout"
# Filter by scope
vendor/bin/scafera logs:filter --scope=app
vendor/bin/scafera logs:filter --scope=framework
# Combine filters (AND logic — all conditions must match)
vendor/bin/scafera logs:filter --level=error --scope=app
# Limit results (default 50 for both logs:filter and logs:errors)
vendor/bin/scafera logs:errors --limit=10
# Clear the log file
vendor/bin/scafera logs:clear
# JSON output (all commands)
vendor/bin/scafera logs:status --json
vendor/bin/scafera logs:errors --json
vendor/bin/scafera logs:stats --json
vendor/bin/scafera logs:recent --json
vendor/bin/scafera logs:filter order.created --jsonAll commands support --json for machine-readable output. The format uses a meta + data envelope:
{
"meta": {"command": "logs:stats", "env": "dev", "file": "/app/var/log/dev.log"},
"data": [...]
}None. The bundle registers StreamLogger with %kernel.logs_dir% and %kernel.environment% — one logger, one file per environment. No config keys, no extension points.
While it is possible to override the logger by aliasing Psr\Log\LoggerInterface to your own class, this is not recommended. Scafera packages are designed to work together — StreamLogger produces the JSON Lines format that the CLI commands (logs:stats, logs:filter, logs:errors, logs:status) depend on. A custom implementation that changes the output format will break CLI compatibility. Prefer staying with StreamLogger unless you have a specific need that it cannot satisfy.
# Not recommended — only if StreamLogger truly cannot meet your needs
services:
Psr\Log\LoggerInterface:
alias: App\CustomLoggerIf you must override, your implementation should write JSON Lines to var/log/{environment}.log with the same field structure (timestamp, level, message, event, context) to maintain CLI compatibility.
A built-in adapter for monolog/monolog for applications needing log routing, multiple outputs, or advanced formatting. Deferred — StreamLogger covers the common case, and applications can wire Monolog manually via the LoggerInterface alias override.
MIT