Skip to content

Logging

Marko’s logging system separates the logging contract from its storage backend. Your application code injects LoggerInterface and calls level-specific methods --- the underlying driver (file, database, or any custom implementation) is configured through bindings. This guide covers setup, everyday usage, customization, and testing.

Install the contracts package and a driver:

Terminal window
composer require marko/log marko/log-file

The marko/log-file package registers its binding automatically via module.php, wiring LoggerInterface to a FileLogger created through FileLoggerFactory.

All log settings live in config/log.php:

config/log.php
<?php
declare(strict_types=1);
return [
'driver' => $_ENV['LOG_DRIVER'] ?? 'file',
'path' => $_ENV['LOG_PATH'] ?? 'storage/logs',
'level' => $_ENV['LOG_LEVEL'] ?? 'debug',
'channel' => $_ENV['LOG_CHANNEL'] ?? 'app',
'format' => '[{datetime}] {channel}.{level}: {message} {context}',
'date_format' => 'Y-m-d H:i:s',
'max_files' => (int) ($_ENV['LOG_MAX_FILES'] ?? 30),
'max_file_size' => (int) ($_ENV['LOG_MAX_FILE_SIZE'] ?? 10 * 1024 * 1024),
];
KeyDefaultDescription
driverfileActive logging driver
pathstorage/logsDirectory where log files are written
leveldebugMinimum severity --- messages below this are skipped
channelappChannel name used in filenames and log output
format[{datetime}] {channel}.{level}: {message} {context}Log line format
date_formatY-m-d H:i:sTimestamp format
max_files30Days of logs to keep (used by log:clear)
max_file_size10485760Max file size in bytes for size-based rotation (10 MB)

Override any value via environment variables (LOG_DRIVER, LOG_PATH, LOG_LEVEL, LOG_CHANNEL, LOG_MAX_FILES, LOG_MAX_FILE_SIZE).

Inject LoggerInterface and call level-specific methods:

use Marko\Log\Contracts\LoggerInterface;
class OrderService
{
public function __construct(
private LoggerInterface $logger,
) {}
public function placeOrder(
int $orderId,
): void {
$this->logger->info('Order placed', ['order_id' => $orderId]);
}
}

Eight severity levels via the LogLevel enum, from most to least severe:

LevelDescription
EmergencySystem unusable
AlertImmediate action required
CriticalCritical conditions
ErrorRuntime errors
WarningExceptional but non-error conditions
NoticeNormal but significant events
InfoInteresting events
DebugDetailed debug information

Use level-specific methods for convenience, or pass a LogLevel explicitly:

use Marko\Log\Contracts\LoggerInterface;
use Marko\Log\LogLevel;
$this->logger->error('Payment gateway timeout');
$this->logger->warning('Disk space low', ['free_mb' => 120]);
$this->logger->debug('Cache miss', ['key' => 'user.42']);
// Equivalent to $this->logger->warning(...)
$this->logger->log(LogLevel::Warning, 'Disk space low', ['free_mb' => 120]);

Messages below the configured minimum level are silently skipped. For example, if log.level is set to warning, calls to info() and debug() produce no output.

Every log method accepts a context array. Context values are attached to the log record and serialized as JSON in the output:

use Marko\Log\Contracts\LoggerInterface;
$this->logger->error('Import failed', [
'file' => 'products.csv',
'line' => 42,
'reason' => 'Invalid SKU format',
]);

Output:

[2025-01-15 10:30:00] app.ERROR: Import failed {"file":"products.csv","line":42,"reason":"Invalid SKU format"}

Context values can be interpolated into the message using PSR-3 style {key} placeholders:

use Marko\Log\Contracts\LoggerInterface;
$this->logger->error('Payment failed for order {order_id}', [
'order_id' => 1234,
]);

The {order_id} placeholder is replaced with 1234 in the formatted output. Placeholders work with string, numeric, and __toString-capable values.

The file driver supports two rotation strategies:

Daily rotation (default) --- one file per day, date embedded in the filename:

storage/logs/app-2025-01-15.log
storage/logs/app-2025-01-16.log

Size rotation --- rotates when a file exceeds the configured size limit (default: 10 MB):

storage/logs/app.log
storage/logs/app.1.log
storage/logs/app.2.log

Use the CLI command to remove old log files:

Terminal window
# Clear logs older than configured max_files days (default: 30)
marko log:clear
# Clear logs older than 7 days
marko log:clear --days=7

Replace the default daily rotation with size-based rotation via Preference:

use Marko\Core\Attributes\Preference;
use Marko\Log\File\Rotation\DailyRotation;
use Marko\Log\File\Rotation\SizeRotation;
#[Preference(replaces: DailyRotation::class)]
class LargeFileRotation extends SizeRotation
{
public function __construct()
{
parent::__construct(maxSize: 50 * 1024 * 1024); // 50 MB
}
}

Replace the default LineFormatter with a custom formatter --- for example, JSON output:

use Marko\Core\Attributes\Preference;
use Marko\Log\Contracts\LogFormatterInterface;
use Marko\Log\Formatter\LineFormatter;
use Marko\Log\LogRecord;
#[Preference(replaces: LineFormatter::class)]
class JsonFormatter implements LogFormatterInterface
{
public function format(
LogRecord $record,
): string {
return json_encode([
'level' => $record->level->value,
'message' => $record->interpolatedMessage(),
'channel' => $record->channel,
'datetime' => $record->datetime->format('c'),
'context' => $record->context,
]) . "\n";
}
}

Implement LoggerInterface to create an entirely new backend --- for example, a database or external service driver:

use Marko\Log\Contracts\LoggerInterface;
use Marko\Log\LogLevel;
class DatabaseLogger implements LoggerInterface
{
public function __construct(
private LogLevel $minimumLevel,
) {}
public function emergency(string $message, array $context = []): void
{
$this->log(LogLevel::Emergency, $message, $context);
}
public function alert(string $message, array $context = []): void
{
$this->log(LogLevel::Alert, $message, $context);
}
public function critical(string $message, array $context = []): void
{
$this->log(LogLevel::Critical, $message, $context);
}
public function error(string $message, array $context = []): void
{
$this->log(LogLevel::Error, $message, $context);
}
public function warning(string $message, array $context = []): void
{
$this->log(LogLevel::Warning, $message, $context);
}
public function notice(string $message, array $context = []): void
{
$this->log(LogLevel::Notice, $message, $context);
}
public function info(string $message, array $context = []): void
{
$this->log(LogLevel::Info, $message, $context);
}
public function debug(string $message, array $context = []): void
{
$this->log(LogLevel::Debug, $message, $context);
}
public function log(
LogLevel $level,
string $message,
array $context = [],
): void {
if (!$level->meetsThreshold($this->minimumLevel)) {
return;
}
// Write to your database table here
}
}

Then bind it in your module.php:

module.php
<?php
declare(strict_types=1);
use Marko\Log\Contracts\LoggerInterface;
use App\Blog\Logger\DatabaseLogger;
return [
'bindings' => [
LoggerInterface::class => DatabaseLogger::class,
],
];

The marko/testing package provides FakeLogger --- an in-memory implementation of LoggerInterface that captures all log entries for assertions.

use Marko\Log\LogLevel;
use Marko\Testing\Fake\FakeLogger;
$logger = new FakeLogger();
$logger->info('Order placed', ['order_id' => 42]);
$logger->error('Payment failed');
// Assert a message was logged
$logger->assertLogged('Order placed');
// Assert a message was logged at a specific level
$logger->assertLogged('Payment failed', LogLevel::Error);
// Assert nothing was logged
$logger = new FakeLogger();
$logger->assertNothingLogged();

Use entriesForLevel() to inspect entries at a specific severity:

use Marko\Log\LogLevel;
use Marko\Testing\Fake\FakeLogger;
$logger = new FakeLogger();
$logger->info('Info message');
$logger->error('Error message');
$errors = $logger->entriesForLevel(LogLevel::Error);
// [['level' => LogLevel::Error, 'message' => 'Error message', 'context' => []]]

Marko auto-loads a toHaveLogged expectation for cleaner test assertions:

use Marko\Log\LogLevel;
use Marko\Testing\Fake\FakeLogger;
$logger = new FakeLogger();
$logger->warning('Slow query detected');
expect($logger)->toHaveLogged('Slow query detected');
expect($logger)->toHaveLogged('Slow query detected', LogLevel::Warning);

Pass FakeLogger as a constructor dependency to verify logging behavior:

use Marko\Testing\Fake\FakeLogger;
it('logs when an order is placed', function () {
$logger = new FakeLogger();
$service = new OrderService(logger: $logger);
$service->placeOrder(orderId: 42);
expect($logger)->toHaveLogged('Order placed');
});