sugarcraft / sugar-log
PHP port of charmbracelet/log — minimal, colorful leveled logging with structured human-readable output, text/JSON/logfmt formatters, and stdlog adapter.
Requires
- php: ^8.3
- psr/log: ^3.0
- sugarcraft/sugar-colorprofile: dev-master
- sugarcraft/sugar-core: dev-master
- sugarcraft/sugar-style: dev-master
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-05 01:02:12 UTC
README
SugarLog
Formerly published as
sugarcraft/candy-log(renamed 2026-10).
sugar-log — a minimal, colorful leveled logging library for PHP 8.3+.
Features
- Leveled logging —
Debug,Info,Warn,Error,Fatallevels - Colorful human-readable output — styled when the destination is a color-capable terminal (honors
NO_COLOR, force withCLICOLOR_FORCE; plain bytes on files and pipes by design) - Multiple formatters —
TextFormatter(default),JsonFormatter,LogfmtFormatter - Structured key/value pairs — pass arbitrary context with every log call
- Sub-loggers —
with([...])creates a child logger with persistent fields (a channel analogue: give each subsystem its ownwithPrefix()/with()child) - Per-field styling — the
Stylesinstance propertyvaluesmaps individual field names to their styles;keysstyles the generic key and value roles - Threshold-friendly levels — integer values (-4/0/4/8/12, charmbracelet/log-aligned deltas — not RFC 5424 severities) for easy threshold filtering
- stdlog adapter —
StandardLogAdapteroffers a Go-style call pattern (print/printLn/fatal/panic) over a sugar-log logger — a shape, not an interface: it implements no external contract (see Stdlog adapter) - PSR-3 bridge —
PsrBridgeimplementsPsr\Log\LoggerInterface; the eight severity methods never throw (a failed write lands as an internal notice, not an exception), whilelog()rejects unknown level strings withInvalidArgumentExceptionas PSR-3 itself prescribes - Record integrity — colliding context keys are namespaced (
ctx_), logfmt values escape\and", control bytes are neutralised (see Record integrity) - Error-stream routing — optional second stream for severe records (threshold defaults to
Error) without moving everything else offSTDOUT - Hook system — register callbacks per log level via
HookRegistry::onLevel() - Configurable log-part ordering —
PartsOrderDTO controls which parts appear and in what sequence
Install
composer require sugarcraft/sugar-log
Stability: this package requires its
sugarcraft/siblings atdev-master. A Composer project left on the defaultstableminimum-stability cannot resolve them — add"minimum-stability": "dev", "prefer-stable": trueto the consuming root (or pin the releasedsugarcraft/*tags once cut). The monorepo publishes all libs together, so sibling versions always line up.
Quick Start
use SugarCraft\Log\Logger; use SugarCraft\Log\Level; $log = Logger::new(level: Level::Debug); // default threshold is Info; Debug opts in $log->debug('preheat cycle', ['stage' => 1]); $log->info('Starting oven', ['degree' => 375]); $log->warn('Almost ready', ['batch' => 2]); $log->error('Temperature too low', ['err' => 'underheated']);
Every Logger constructor call also takes formatter:/stream:/prefix: — see Output and streams.
Levels
Levels carry charmbracelet/log-aligned integer deltas (Debug -4 through
Fatal 12 — deliberately not RFC 5424 severities) — use ->value for
threshold comparisons. A record is emitted when its level value is at or
above the logger's minLevel (default Info, hence the Debug opt-in
above; print() and its formatted sibling printf() are the two methods
that bypass the filter):
use SugarCraft\Log\Level; use SugarCraft\Log\Logger; Level::Debug->value; // -4 Level::Info->value; // 0 Level::Warn->value; // 4 Level::Error->value; // 8 Level::Fatal->value; // 12 $log = Logger::new(); // threshold: Info $log->info('info message'); $log->warn('warn message'); $log->error('error message'); try { $log->fatal('fatal message'); // writes the line, then throws RuntimeException } catch (RuntimeException) { // the port's stop-signal: Go's log.Fatal would exit(1) right here } $log->print('always prints'); // bypasses the level FILTER — the label still renders
Static Facade
Log is a process-shared façade over one lazily-created default Logger — the
package-level function style of charmbracelet/log:
use SugarCraft\Log\Log; Log::info('baking started', ['temp' => 375]); Log::warn('timer almost up'); Log::error('power cut'); // The shared logger's threshold is Info, so Log::debug('...') is dropped // until it is lowered: Log::default()->setMinLevel(Level::Debug); try { Log::fatal('out of flour'); // writes, then throws RuntimeException } catch (RuntimeException) { // handle, or exit deliberately — this is where Go's Fatal would stop } $fh = fopen('php://stderr', 'w'); Log::default()->setOutput($fh); // reach the shared logger itself fclose($fh);
Swap the shared logger wholesale with Log::setLogger(Logger::new(...)), and
Log::reset() returns to the lazily-created default.
Structured Fields
use SugarCraft\Log\Logger; $log = Logger::new(); $log->info('Baking cookies', [ 'flour' => '2 cups', 'butter' => true, 'temp' => 375, ]); // Child logger with persistent fields $baker = $log->with(['user' => 'chef', 'session' => 'am']); $baker->info('Batch started'); // also has user + session
Formatters
use SugarCraft\Log\Formatter\TextFormatter; use SugarCraft\Log\Formatter\JsonFormatter; use SugarCraft\Log\Formatter\LogfmtFormatter; use SugarCraft\Log\Logger; $log = Logger::new(formatter: new JsonFormatter()); // structured output $log = Logger::new(formatter: new LogfmtFormatter()); // logfmt key=value $log = Logger::new(formatter: new TextFormatter()); // human line (the default)
JsonFormatter and LogfmtFormatter take an optional second constructor
argument, $timeFormat — a DateTime format string; it defaults to
DateTimeInterface::ATOM because machine parsers pin timestamps to that
shape, so change it deliberately, not casually. Inside a text line,
array context values render as [a b c]: elements joined positionally,
associative keys not shown — use JSON or logfmt when key names matter.
Styling
Styles are applied only when the destination can render them. A Logger
consults sugar-colorprofile's Probe and — for each write — the stream it is
about to use: TextFormatter output reaches a file or a piped stream as plain
bytes, never as baked-in ANSI escapes. Set NO_COLOR to force plain output on
terminals, or CLICOLOR_FORCE=1 to opt styling back in for captured output
(FORCE_COLOR is not a knob this fleet reads). A standalone
new TextFormatter(useColors: true) still emits SGR by contract — the gate
lives in the Logger, not in the formatter.
Override level styles via Logger::styles():
Shared singleton:
Logger::styles()returns the process-wideStyles::default()instance. Mutating it changes every logger that was not given its ownStyles. For per-logger styling, build a freshStyles, start fromStyles::snapshot()— a private copy (mutable arrays fresh,Stylevalue-objects shared) safe to edit without touching the process default — or hand one to the formatter, and install it withsetStyles():
use SugarCraft\Core\Util\Color; use SugarCraft\Log\Level; use SugarCraft\Log\Logger; use SugarCraft\Log\Styles; use SugarCraft\Style\Style; $log = Logger::new(); $styles = new Styles(); $styles->levels[Level::Error->value] = Style::new()->foreground(Color::parse('red'))->bold(); $log->setStyles($styles);
Per-field styles
Styles::keys maps the log parts (time, level, prefix, caller,
message) and the field-pair roles (key, value) to individual Style
objects. Part roles ship as do-nothing styles (raw bytes, no SGR); the
formatter falls back to the matching property ($timestamp, $prefix,
$caller, $message) or the level colour map when a part style is unset:
use SugarCraft\Core\Util\Color; use SugarCraft\Log\Logger; use SugarCraft\Log\Styles; use SugarCraft\Style\Style; $log = Logger::new(); $styles = new Styles(); $styles->keys['time'] = Style::new()->foreground(Color::parse('cyan')); $styles->keys['level'] = Style::new()->foreground(Color::parse('magenta')); // wins over the level map $styles->keys['message'] = Style::new()->italic(); $styles->keys['value'] = Style::new()->foreground(Color::parse('green')); // splits k=v into segments $styles->values['batch'] = Style::new()->foreground(Color::parse('magenta')); // per-field override $log->setStyles($styles);
High-contrast environments
The shipped defaults put Debug, the caller tag and generic keys on
bright-black — roughly 2.5-3:1 against the default background, marginal for
long reading sessions. Labels always carry text (color is never the only
channel), but where contrast matters, override the roles explicitly:
use SugarCraft\Core\Util\Color; use SugarCraft\Log\Level; use SugarCraft\Log\Logger; use SugarCraft\Log\Styles; use SugarCraft\Style\Style; $log = Logger::new(); $styles = Styles::snapshot(); $styles->levels[Level::Debug->value] = Style::new()->foreground(Color::parse('white')); $styles->levels[Level::Info->value] = Style::new()->foreground(Color::parse('cyan'))->bold(); // pure blue is the other marginal one $styles->keys['key'] = Style::new()->foreground(Color::parse('white')); $styles->caller = Style::new()->foreground(Color::parse('white'))->bold(); $log->setStyles($styles);
Without a value style, each context pair is rendered as one key=value run
using keys['key']; setting keys['value'] (or a per-field values[$key])
renders key and value as separate styled segments around a bare =.
Level text alignment
Styles::padLevelText($label) right-pads a level label to 5 characters for
column-aligned log output:
use SugarCraft\Log\Styles; Styles::padLevelText('INFO'); // "INFO " Styles::padLevelText('DEBUG'); // "DEBUG"
Set the padLevelText property on a Styles instance to make
TextFormatter apply the padding to every rendered level label:
use SugarCraft\Log\Logger; use SugarCraft\Log\Styles; $log = Logger::new(); $styles = new Styles(); $styles->padLevelText = true; // renders "INF " / "DEBUG" instead of "INF" / "DEBUG" $log->setStyles($styles);
Output and streams
By default every level is written to STDOUT. This deliberately deviates
from charmbracelet/log, which writes to os.Stderr; the single-stream default
is kept because existing consumers rely on it. Two knobs change where records
go:
use SugarCraft\Log\Formatter\TextFormatter; use SugarCraft\Log\Logger; use SugarCraft\Log\Level; // 1. Custom stream — send a logger anywhere (immutable: returns a new Logger) $log = Logger::new(); $fh = fopen('/tmp/app.log', 'a'); // plain bytes on files: styling is auto-dropped $out = $log->withOutput($fh); // create with 0600 (umask) in real deployments // 2. Error-stream routing — severe records go to a second stream $err = fopen('php://stderr', 'w'); $log = Logger::new(errorStream: $err); // Error+Fatal → STDERR $log = Logger::new(errorStream: $err, errorStreamAt: Level::Warn); // Warn and above → STDERR $one = $log->withErrorStream(null); // back to a single stream
Routing is decided per record when it is emitted; Fatal still throws
RuntimeException after writing. Constructor and setOutput()/
withOutput()/setErrorStream()/withErrorStream() reject non-resource
streams with an InvalidArgumentException. A record that cannot be written
(closed or unwritable handle) never throws: it is retried once after a flush
and the loss is announced as a sanitized [sugar-log] log write failed:
notice on STDERR. File streams are buffered by PHP — call $log->flush()
before hard-exit paths (SIGKILL, posix_kill, exit() under a fatal
framework) so nothing still in the buffer is lost.
Record integrity
Untrusted data can never forge or reshape a record:
- Sanitisation — text reaching
TextFormatterandLogfmtFormatterpasses throughSanitize::untrusted(), stripping ESC-based injection (terminal repositioning, hyperlinks, image markers) before anything is written;JsonFormattersweeps the same control bytes from message, prefix, caller and every context key and value before encoding (json_encode escapes C0 bytes but passes C1 bytes through verbatim), and substitutes invalid UTF-8 instead of letting it fail the encode. - logfmt escaping — backslash and double-quote inside values are escaped
(
\\,\"), newline/CR/tab become\n/\r/\t, and a value is quoted whenever it contains whitespace,=,", or\. - Reserved keys — context keys colliding with the record envelope
(
level,msg,time,caller,prefix,_encode_error) are renamed with a repeatedctx_prefix in both JSON and logfmt output (level→ctx_level,ctx_level→ctx_ctx_level). Decoded records therefore always carry the real level, time and message no matter what a context array contains. - Hostile shapes — reference cycles in context terminate at a depth cap
(
[...]), non-finite floats become'INF'/'-INF'/'NAN', a throwing__toString()is contained (the object reports its class name), and if an encode fails anyway the JSON fallback drops only the offending field — the envelope and every other entry survive, flagged by_encode_error.
Panic Handlers
use SugarCraft\Log\Log; // Install a panic handler that catches uncaught exceptions and fatal errors, // restores the terminal from altscreen mode, and prints a panic report — // styled when STDERR is a color-capable terminal (or CLICOLOR_FORCE=1), // plain ANSI-free text everywhere else. Log::installPanicHandler(); // Restore terminal state manually (exit altscreen, show cursor). // Called automatically by the panic handler, but safe to call directly. Log::restoreTerminal();
The panic handler catches uncaught exceptions and fatal PHP errors (E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR), restores the terminal to a usable state, and prints a banner with the error class, message, and backtrace — colorized only when the destination supports it. The exception message is treated as untrusted input: terminal-control bytes are swept and newlines flattened, so a message that embeds user data can never forge extra report rows or emit escape sequences into whoever displays it, and redactPaths substrings are stripped from it as they are from frames and locals. Re-installing replaces the previous sugar-log handler instead of stacking. Internal frames with no file/line render honestly as ?:?, and the report ends with a neutral footer identifying sugar-log for issue filing. showLocals dumps call arguments only for traces that carry them — PHP's engine omits args from exception traces, so for plain uncaught exceptions there are no locals to show; the knob is live for manually injected frame arrays.
PSR-3 Bridge
PsrBridge wraps a Logger instance and implements Psr\Log\LoggerInterface: all nine methods (emergency, alert, critical, error, warning, notice, info, debug, log) are real interface methods, so the bridge satisfies a LoggerInterface type-hint anywhere a PSR-3 logger is expected. The eight severity methods never throw — a hostile __toString(), a formatter that raises, or the RuntimeException that backs Fatal are all contained and announced as sanitized notices on STDERR, never propagated into the caller. Only log(), which takes the PSR-3 spec's mixed $level, may raise: an unrecognized or non-string level is an InvalidArgumentException, as PSR-3 itself prescribes for that method:
use SugarCraft\Log\Logger; use SugarCraft\Log\PsrBridge; use Psr\Log\LogLevel; $logger = new Logger(); $psr = new PsrBridge($logger); // All PSR-3 methods available $psr->warning('Something is off', ['detail' => 'temperature rising']); $psr->log(LogLevel::ERROR, 'Operation failed', ['code' => 500]);
Level mapping, in one place: emergency/alert/critical → Fatal
(the record carries the FTL label and routes to the error stream, but the
bridge swallows the RuntimeException that backs Fatal — Go's exit is not
portable here); error → Error; warning → Warn; notice and info →
Info; debug → Debug.
The bridge also fires registered hooks after the record is written (see Hook System), enabling middleware-style interceptors. A hook that throws is contained per-handler the same way — one hostile callback can never cancel the walk or eat the log line.
Hook System
The hook system lets you register callbacks that fire whenever a log entry is emitted at or above a given level. Hooks receive the Level, PSR-3 level string, message, and context — useful for dispatching to external services, enriching context, or filtering.
Note: Hooks fire only via
PsrBridge— they do not fire when callingLogger->info()etc. directly. For the plainLoggerAPI, hooks are not invoked. To use hooks, wrap yourLoggerin aPsrBridgeand call methods on the bridge.
use SugarCraft\Log\Logger; use SugarCraft\Log\Level; use SugarCraft\Log\Hook\HookRegistry; use SugarCraft\Log\PsrBridge; $logger = new Logger(); $hooks = new HookRegistry(); // Register a callback for all Warn-and-above entries $id = $hooks->onLevel(Level::Warn, function (Level $level, string $psrLevel, string $message, array $context) { // Dispatch to external service, enrich context, etc. file_put_contents('/tmp/warn.log', "[{$level->label()}] {$message}\n", FILE_APPEND); }); // Wire the registry into the bridge — this is how hooks fire $psr = new PsrBridge($logger, $hooks); $psr->warning('Something is off'); // the hook above runs after the record lands // Unregister when done ($id came from onLevel()) $hooks->remove($id); // The registry can also be fired directly by an integration: $hooks->fire(Level::Warn, 'warning', 'Something is off', []);
HookRegistry::onLevel(Level, callable) returns a registration ID. HookRegistry::fire(Level, psrLevel, message, context) dispatches to all handlers whose minimum level is met, and remove($id) unregisters a handler (unknown ids are ignored).
The Hook interface is also available for structured implementations:
use SugarCraft\Log\Hook\Hook; use SugarCraft\Log\Level; final class MetricsHook implements Hook { public function onLevel(Level $level, string $psrLevel, string $message, array $context): void { // Ship metrics to your observability platform } }
Parts Order
PartsOrder is a config DTO that controls which log-parts appear and in what sequence when formatting. It ships with three named presets:
use SugarCraft\Log\PartsOrder; // Default: all six parts in canonical order PartsOrder::default(); // [timestamp, level, prefix, caller, message, fields] // Syslog-friendly: omits prefix and caller PartsOrder::syslog(); // [timestamp, level, message, fields] // Message-first: message comes before level and timestamp PartsOrder::messageFirst(); // [message, level, timestamp, fields] // Custom ordering $order = new PartsOrder([PartsOrder::PART_MESSAGE, PartsOrder::PART_LEVEL, PartsOrder::PART_FIELDS]); // Query whether a part is included $order->has(PartsOrder::PART_CALLER); // false for syslog(), true for default()
Named part constants: PART_TIMESTAMP, PART_LEVEL, PART_PREFIX, PART_CALLER, PART_MESSAGE, PART_FIELDS.
The constructor rejects unknown or duplicated parts with an InvalidArgumentException
naming the offenders — a typo'd 'msg' would otherwise silently delete the
message from every rendered line. PartsOrder is honored only by
TextFormatter; JSON and logfmt ignore it (a JSON object has no member order,
a logfmt record is fixed-shape).
Caller Information
Logger::setReportCaller(true) is the normal switch — the logger then tags
each line automatically. Underneath, CallerFormatter::find() walks the call
stack (skipping at most 20 frames) and returns "basename:line" of the first
frame outside the log package, or null when the call site is not found
within that budget:
use SugarCraft\Log\CallerFormatter; $caller = CallerFormatter::find(); // e.g. "my-script.php:42"
use SugarCraft\Log\Logger; $log = Logger::new(reportCaller: true); $log->info('who called me'); // line carries a <file:line> tag
Stdlog adapter
StandardLogAdapter mirrors the shape of Go's log.Logger methods —
print()/printLn()/fatal()/panic() — over a sugar-log Logger. Be
honest about the boundary: PHP has no *log.Logger interface and no
net/http, so the adapter implements no contract; it is a call-pattern
bridge (hand it to code written against that method roster):
use SugarCraft\Log\Level; use SugarCraft\Log\Logger; use SugarCraft\Log\StandardLogAdapter; $logger = Logger::new(level: Level::Debug); $stdlog = new StandardLogAdapter($logger); $stdlog->printLn('GET /health'); // lands like Logger::print(): INF label, bypasses the minLevel filter try { $stdlog->fatal('listen :80 failed'); // writes the record, then throws RuntimeException } catch (RuntimeException) { // Go's Fatal would exit(1) here }
An adapter built with a forced level above Info keeps the bypass only for
the un-forced path: once a level is forced, print() goes through the
minLevel filter like any ordinary call at that level.
Sample output
Three formatters, three records, no color (what a file sink or pipe receives):
07:15:04 WRN bakery <baker.php:88> Almost ready batch=2 oven=convection
{"level":"ERROR","msg":"Temperature too low","time":"2026-11-03T07:15:04+00:00","caller":"oven.php:31","prefix":"bakery","err":"underheated"}
time=2026-11-03T07:15:04+00:00 level=INFO msg="Starting oven" prefix=bakery degree=375
Translations
Error-path prose — the two messages thrown by Logger (logger.fatal,
logger.invalid_stream) — is resolved through sugar-core's Lang lookup in
en plus 15 locales (exact → base → en → raw). Log content (level labels,
timestamps, field text) is never translated. Selection is the host
application's duty: sugar-log never changes locale on its own — set it
through sugar-core's i18n entry point at process start, and the two
error-path messages resolve accordingly.
License
MIT
Credits & inspiration
Originally inspired by the Go Charm ecosystem; SugarCraft is developed as a native PHP project.