Purpose

Scriptor's default PSR-3 logger. One file, one line per record, greppable with the usual tools. Bound into the DI container as Psr\Log\LoggerInterface so theme code ($site->logger), editor code ($editor->logger), and plugin code all reach the same sink.

Intentionally small. Records below the configured min level are dropped silently; nothing else is filtered, decorated, or routed. Swap the binding in boot.php for Monolog or any PSR-3 implementation when you need rotation, multiple sinks, or structured output.

FQCN + file path

When to use

You almost never touch FileLogger directly. The shape every caller uses is the PSR-3 interface:

$this->logger->info('Contact form submitted from {email}', [
    'email' => $email,
]);

Themes have it on $site->logger, the editor surface has it on $editor->logger, plugins resolve it from the container:

$logger = $context->container()->get(\Psr\Log\LoggerInterface::class);

Direct construction of FileLogger is for two cases:

  • Swapping the binding in boot.php to point at a different file or min level than the configured defaults.
  • A standalone script (CLI tool, cron job) that runs outside the request lifecycle and wants logs in the same file.

Surface

Constructor

public function __construct(
    private readonly string $path,
    string $minLevel = LogLevel::INFO,
)
  • $path is the absolute file path. The parent directory is created lazily on the first write, so data/logs/ does not have to exist beforehand.
  • $minLevel is a PSR-3 level constant (Psr\Log\LogLevel::DEBUG, INFO, NOTICE, WARNING, ERROR, CRITICAL, ALERT, EMERGENCY). Records below this level are dropped without going through the format step. Unknown level strings raise InvalidArgumentException at construction time.

Logging methods

Inherited from Psr\Log\AbstractLogger (and therefore the PSR-3 LoggerInterface):

public function emergency(string|\Stringable $message, array $context = []): void
public function alert    (string|\Stringable $message, array $context = []): void
public function critical (string|\Stringable $message, array $context = []): void
public function error    (string|\Stringable $message, array $context = []): void
public function warning  (string|\Stringable $message, array $context = []): void
public function notice   (string|\Stringable $message, array $context = []): void
public function info     (string|\Stringable $message, array $context = []): void
public function debug    (string|\Stringable $message, array $context = []): void

public function log(mixed $level, string|\Stringable $message, array $context = []): void

All eight level shortcuts forward to log(), which is the only method FileLogger overrides.

Configuration

The default container binding reads from scriptor-config.php:

return [
    // ...
    'logging' => [
        'path'  => __DIR__ . '/data/logs/scriptor.log',
        'level' => 'info',     // PSR-3 LogLevel constant string
    ],
];

Both keys are optional. The wiring in boot.php falls back to data/logs/scriptor.log + info if either is missing.

Output shape

One line per record, Monolog-ish on purpose so the same grep patterns work across Scriptor and Monolog-using sites:

[2026-05-22T14:23:00+02:00] INFO: Contact form submitted from juri@example.com

The timestamp is ISO 8601 (date('c')), the level is uppercased, the message is post-interpolation.

Placeholder interpolation (PSR-3 §1.2)

{key} tokens in the message are substituted from $context[key]:

$logger->info('user {id} edited page {slug}', [
    'id'   => 42,
    'slug' => 'home',
]);
// -> [2026-...] INFO: user 42 edited page home

Substitution rules:

$context[key] value Becomes
null, scalar, or Stringable (string) $value
Throwable $value->getMessage() (NOT the stack trace)
Anything else (array, non-stringable object) Untouched; the literal {key} stays in the message

The Throwable-special-case is deliberate: a Throwable's __toString() returns the full stack trace, which would explode one log line into hundreds. Loggers want the message; downstream handlers that want the trace read it from $context['exception'] per PSR-3 §1.3.

Lifecycle

Container-bound singleton, request-scoped (one per request, one shared instance across every caller). Constructed once by the container factory; the file descriptor is not held open between records (every write opens, appends, closes via file_put_contents with FILE_APPEND | LOCK_EX).

Concurrency is handled by LOCK_EX: PHP-FPM workers writing the same file serialise correctly. There is no record buffer; every call hits the filesystem. For high-volume logging move to a real PSR-3 implementation.

Common patterns

Plain info log with placeholders

$site->logger->info('payment {id} authorised for {amount}', [
    'id'     => $paymentId,
    'amount' => number_format($amount, 2),
]);

Logging a caught exception with context

try {
    $this->processor->run($file);
} catch (\Throwable $e) {
    $site->logger->error('processing {file} failed: {error}', [
        'file'      => $file->name,
        'error'     => $e,           // -> getMessage() inline
        'exception' => $e,           // -> downstream handlers can read the trace
    ]);
}

Lifting min level temporarily in scriptor-config.php

'logging' => [
    'path'  => __DIR__ . '/data/logs/scriptor.log',
    'level' => 'debug',     // include debug + info for a debugging session
],

Swapping in Monolog (rotation, multiple handlers)

// In boot.php, after the default binding:
$container->add(
    \Psr\Log\LoggerInterface::class,
    static function () {
        $log = new \Monolog\Logger('scriptor');
        $log->pushHandler(new \Monolog\Handler\RotatingFileHandler(
            __DIR__ . '/data/logs/scriptor.log',
            14,   // keep 14 days
        ));
        return $log;
    },
);

The site continues to call $site->logger->info(...); only the implementation changes.

See also