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
LoggerManagerwith 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.
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.
monolog/monolog present → real Monolog channel → formatted output
monolog/monolog absent → Psr\Log\NullLogger → nothing, no errorSo 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
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 → outputWhen to reach for what
- ✅ 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
- Do it hands-on: Quickstart
- Task recipes: Request context · Dynamic channels
- The authoritative surface: API reference