Logging
Lift\Log\Logger is a PSR-3 logger with pluggable handlers and formatters. It supports the eight standard log levels, placeholder interpolation, and stacks of independent handlers (e.g. write JSON to a file and coloured lines to stdout simultaneously).
Mental model: a logger receives messages. Handlers decide where they go (file, stdout, syslog, /dev/null). Formatters decide what they look like (JSON, plain line, etc.). One logger, many handlers, each with its own formatter and minimum level.
When and how much to log
- Errors and warnings: always. Otherwise you'll never know your app is broken.
- Important business events: yes, with
info(). ("Order #1234 placed", "User signed up".) - Debugging detail:
debug()— turn on only in dev / for selected requests. - PII: never. Mask emails, redact tokens. Logs are the #1 place secrets leak.
30-second example
use Lift\Log\Logger;
use Lift\Log\Handler\FileHandler;
use Lift\Log\Handler\StdoutHandler;
use Lift\Log\Formatter\JsonFormatter;
$logger = new Logger([
new FileHandler('/var/log/myapp.log', 'debug', new JsonFormatter()),
new StdoutHandler('warning'),
]);
$logger->info('User logged in', ['user_id' => 42]);
$logger->error('Payment failed', ['order_id' => 123, 'exception' => $e]);
$logger->warning('Hot-cache miss', ['key' => 'user:42']);
The same info(...) call goes through both handlers: the file gets a full JSON line, stdout sees nothing (level filter), and a warning() call would hit both.
PSR-3 levels
Standard severities, ordered most → least severe:
| Method | Level | Use for |
|---|---|---|
emergency() |
emergency |
System is unusable |
alert() |
alert |
Action must be taken immediately |
critical() |
critical |
Critical conditions (component down) |
error() |
error |
Errors that need attention but don't stop the app |
warning() |
warning |
Something fishy, may become an error |
notice() |
notice |
Normal but significant events |
info() |
info |
Routine operational events |
debug() |
debug |
Detailed debug-only info |
Or the generic log($level, $message, $context).
Wiring into Lift
App does not register a logger automatically — register your own:
use Lift\Log\Logger;
use Lift\Log\Handler\FileHandler;
use Lift\Log\Formatter\JsonFormatter;
use Psr\Log\LoggerInterface;
$app->singleton(Logger::class, fn() => new Logger([
new FileHandler(__DIR__ . '/../storage/logs/app.log', 'info', new JsonFormatter()),
]));
// Bind PSR-3 interface to the same instance — third-party libs accept that
$app->bind(LoggerInterface::class, fn() => $app->make(Logger::class));
Then anywhere — handler, controller, middleware — type-hint and inject:
class UserController
{
public function __construct(private readonly LoggerInterface $log) {}
public function login(Request $req): Response
{
$this->log->info('Login attempt', ['email' => $req->input('email')]);
// …
}
}
Placeholder interpolation
PSR-3 supports {key} placeholders that pull from the context array:
$log->info('User {user_id} did {action}', [
'user_id' => 42,
'action' => 'login',
]);
// → "User 42 did login" (+ the full context array still preserved)
The replacement covers strings, numerics, and any object with __toString(). Other values stay in the context but are not substituted into the message.
Context array
The second argument is a free-form associative array. Conventions:
exception→ pass theThrowable. Most handlers include the stack trace.user_id/request_id/trace_id→ for correlation across services.- Whole
Throwableas a value:
try {
$this->processPayment($order);
} catch (\Throwable $e) {
$this->log->error('Payment processing failed', [
'order_id' => $order->id,
'amount' => $order->total,
'exception' => $e, // formatter renders it
]);
throw $e;
}
Handlers
A handler decides where lines are written and gates them by minimum level. Built-ins:
| Handler | Writes to |
|---|---|
FileHandler |
A single file (creates dir if missing) |
RotatingFileHandler |
Daily-rotated files; prunes old files automatically |
StdoutHandler |
php://stdout |
NullHandler |
Nowhere (useful in tests) |
Each handler takes a minimum level + (optional) formatter:
new FileHandler('/var/log/app.log', minLevel: 'warning', formatter: new JsonFormatter());
new RotatingFileHandler('/var/log/app.log', minLevel: 'info', maxFiles: 30);
new StdoutHandler(minLevel: 'debug'); // default formatter = LineFormatter
new NullHandler();
Adding a handler to an existing logger
withHandler() returns a clone with the extra handler:
$logger = $logger->withHandler(new FileHandler('/tmp/debug.log', 'debug'));
Useful in tests when you want to capture log lines temporarily.
Formatters
A formatter turns a log record into a string. Built-ins:
| Formatter | Output |
|---|---|
LineFormatter |
[2026-05-14 15:30:00] info: User 42 logged in {"user_id":42} |
JsonFormatter |
{"ts":"2026-05-14T15:30:00Z","level":"info","message":"…","context":{…}} |
Pick JsonFormatter for production — it's the format every log-aggregation tool (Loki, ELK, Datadog, CloudWatch) parses for free. Pick LineFormatter for human-readable terminal output.
Custom formatter
use Lift\Log\Formatter\FormatterInterface;
final class CompactFormatter implements FormatterInterface
{
public function format(string $level, string $message, array $context): string
{
return sprintf("%s %-8s %s\n", date('H:i:s'), strtoupper($level), $message);
}
}
new StdoutHandler('debug', new CompactFormatter());
Common configurations
Production — JSON to file + stdout
$app->singleton(Logger::class, fn() => new Logger([
new FileHandler( __DIR__ . '/../storage/logs/app.log', 'info', new JsonFormatter()),
new StdoutHandler('warning', new JsonFormatter()), // container picks this up
]));
- File: every
info+ goes here, for retrospective debugging. - Stdout:
warning+ so it shows up injournalctl/docker logswithout flooding. - Both JSON, so log shippers parse them identically.
Development — coloured lines to stdout
$app->singleton(Logger::class, fn() => new Logger([
new StdoutHandler('debug'), // LineFormatter, all levels
]));
Tests — capture everything in memory
Lift\Log\Handler\NullHandler swallows everything. For tests that assert log content, write a small in-memory handler:
final class ArrayHandler implements HandlerInterface
{
public array $records = [];
public function handle(string $level, string $message, array $context): void
{
$this->records[] = compact('level', 'message', 'context');
}
}
// In your TestCase:
$this->app->instance(LoggerInterface::class, new Logger([$this->logHandler = new ArrayHandler()]));
// Assert
self::assertSame('error', $this->logHandler->records[0]['level']);
Per-request logging middleware
Log every HTTP request:
final class LogRequestsMiddleware implements MiddlewareInterface
{
public function __construct(private readonly LoggerInterface $log) {}
public function process($req, $next): ResponseInterface
{
$t0 = hrtime(true);
$response = $next->handle($req);
$ms = round((hrtime(true) - $t0) / 1e6, 1);
$this->log->info('{method} {path} → {status} ({ms} ms)', [
'method' => $req->getMethod(),
'path' => $req->getUri()->getPath(),
'status' => $response->getStatusCode(),
'ms' => $ms,
]);
return $response;
}
}
$app->use(LogRequestsMiddleware::class);
Log uncaught exceptions
Already shown in Error handling, but for completeness:
$app->onError(function (\Throwable $e, Request $req) use ($app) {
if (!$e instanceof \Lift\Exception\HttpException) {
$app->logger()->error($e->getMessage(), [
'method' => $req->getMethod(),
'path' => $req->getUri()->getPath(),
'exception' => $e,
]);
}
// … return response
});
Log rotation
Built-in: RotatingFileHandler
RotatingFileHandler creates a new file each day and optionally prunes old ones.
use Lift\Log\Handler\RotatingFileHandler;
use Lift\Log\Formatter\JsonFormatter;
new RotatingFileHandler(
path: storage_path('logs/app.log'), // base path
minLevel: 'info',
formatter: new JsonFormatter(),
maxFiles: 30, // keep 30 days; 0 = keep forever
)
Files are named by inserting the date before the extension:
storage/logs/app.log ← base path (not created itself)
storage/logs/app-2026-05-15.log ← today
storage/logs/app-2026-05-14.log ← yesterday
…
The handler opens the correct file lazily on the first write of each day — safe for long-running workers and queue processes. When maxFiles > 0, files beyond the limit are deleted automatically after each rotation.
External rotation (alternative)
Use logrotate with copytruncate when you prefer OS-level rotation:
/var/log/myapp.log {
daily
rotate 14
missingok
notifempty
copytruncate
compress
}
Shipping logs to a third party
Wrap the third party's SDK in a custom handler:
use Lift\Log\Handler\HandlerInterface;
final class SentryHandler implements HandlerInterface
{
public function __construct(private readonly \Sentry\State\HubInterface $sentry) {}
public function handle(string $level, string $message, array $context): void
{
// only ship errors+
if (!in_array($level, ['error', 'critical', 'alert', 'emergency'], true)) {
return;
}
if (isset($context['exception'])) {
$this->sentry->captureException($context['exception']);
} else {
$this->sentry->captureMessage($message);
}
}
}
$app->singleton(Logger::class, fn() => new Logger([
new FileHandler('/var/log/myapp.log', 'info', new JsonFormatter()),
new SentryHandler(Sentry\SentrySdk::getCurrentHub()),
]));
The framework stays dependency-free; you opt into Sentry / Datadog / Loki / etc. via custom handlers.
Security
- Never log passwords, tokens, JWTs, API keys — even at debug level. Logs get archived, shared, leaked.
- Mask emails / PII before passing to
context:$log->info('Signup', ['email_hash' => hash('sha256', $email)]); CookieandAuthorizationheaders: redact them from request-logging middleware. The Debug toolbar does this automatically.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Logs go nowhere | No handler configured | Default is [StdoutHandler] if [] is passed; verify your wiring. |
Permission denied on log file |
Web-server user can't write | chown www-data:www-data storage/logs/ + dir 0755. |
{user_id} literally in output |
The key wasn't in $context (or value isn't stringable) |
Add it to the context array. |
| Logger swallows the stack trace | Passed $e->getMessage() instead of $e itself |
Pass 'exception' => $e. |
| Too verbose under load | info() in a hot loop |
Drop to debug and rely on level filters; or remove the call. |
| Tests pollute the real log file | Bound the production logger in tests | Replace with new Logger([new NullHandler()]) in your TestCase. |
Cheat sheet
// Build
$log = new Logger([
new FileHandler('/var/log/app.log', 'info', new JsonFormatter()),
new StdoutHandler('warning'),
]);
// Use (PSR-3)
$log->emergency / alert / critical / error / warning / notice / info / debug ($msg, $ctx);
$log->log('error', $msg, $ctx);
// Interpolation
$log->info('User {id} did {action}', ['id' => 42, 'action' => 'login']);
// Include throwable
$log->error('Boom', ['exception' => $e]);
// Inject (PSR-3)
public function __construct(private readonly LoggerInterface $log) {}