Package · logger

Mental model

Three ideas explain almost every rule in this library. Hold them, and the API stops feeling like a grab-bag of methods and starts feeling inevitable.

Idea 1 — The library never touches your infrastructure

Winter Logger does not read env vars, detect Docker, inspect the SAPI, or probe for Swoole. Someone else — the framework, or your bootstrap — resolves all of that and hands the manager a finished config: levels, formats, output targets, and a context-storage object.

Think of the logger as a printer, not a switchboard. It prints what it’s told, the way it’s told. Deciding whether today’s runtime is FPM or Swoole, and where stderr should go, is someone else’s job.

This is a deliberate trade-off:

  • Portable & testable — no globals to mock, no env to stub. You construct a LoggerManager with plain data and assert on the output.
  • In exchange — you (or your framework) must resolve infrastructure before building the manager. The library won’t guess for you.

This is why

Every “the framework passes this in” note in these docs traces back here. LoggerManager takes a config array and a ContextStorage; it never derives them itself.

Idea 2 — Context is isolated per runtime, not per library

You want request_id to appear on every log line for the duration of a request — but never bleed into a different request running at the same time. Whether that’s safe depends entirely on the runtime, so the isolation strategy is a swappable object: ContextStorage.

text
FPM  — one request = one process   → ProcessContext   (a plain per-process array)
CLI  — one job     = one process   → ProcessContext
Swoole — many coroutines, one proc → CoroutineContext (a bag per coroutine)

Under FPM each request already gets a fresh process, so a plain array is safe. Under Swoole one process juggles thousands of concurrent coroutines — a plain array would let request A’s user_id show up in request B’s logs. CoroutineContext binds the bag to Swoole\Coroutine::getContext() instead, so each request sees only its own.

The consequence you’ll meet everywhere: in long-lived processes, clear context at the end of each unit of work. FPM tears the process down for you; a Swoole coroutine is cleaned up by Swoole; but a CLI daemon loop reuses one process forever, so you must clear() each iteration or fields leak across jobs.

The full internals — reference bags, the coroutine fallback — are in Deep dive → Context isolation.

Idea 3 — Monolog is the backend, and it’s optional

Winter Logger is a thin PSR-3 layer over Monolog. It builds Monolog channels, attaches processors and handlers, and wraps them so per-class context follows each call. But if Monolog isn’t installed, the whole thing degrades to a NullLogger — silently.

text
monolog/monolog present  →  real Monolog channel  →  formatted output
monolog/monolog absent   →  Psr\Log\NullLogger    →  nothing, no error

So logging is a feature you can turn off by simply not installing a package — no config flag, no code change. The trade-off: if you forget to install Monolog, logs vanish silently rather than throwing. That’s by design (see Monolog is optional).

How the pieces connect

text
LoggerManager   — builds & caches one Monolog channel per config entry
    │            (each channel auto-gets ContextInjectingProcessor)

LoggerFactory   — static facade: getLogger(Class), channel(), Log::*
    │            per-class loggers cached by "channel:FQCN"

Logger          — PSR-3 wrapper; merges bound context into every call


Monolog  →  processors (context, masking)  →  formatter  →  handler  →  output

When to reach for what

Per-class logger getLogger(Class::class)Quick one-liner Log::info(…)Raw channel LoggerFactory::channel(‘http’)
  • ✅ Use getLogger(self::class) in services — you get the class name in every line for free.
  • ✅ Use Log::info(...) for quick, class-agnostic messages on the default channel.
  • ✅ Use channel('name') when you deliberately want a specific channel with no class label.

Next