Logging
Melodic includes a PSR-3-style logging system with daily file rotation, message interpolation, exception formatting, and configurable severity filtering. The logger is designed to never crash your application — if writing fails, the error is silently swallowed.
LogLevel Enum
The Melodic\Log\LogLevel backed enum defines eight severity levels. Each level has a numeric severity where lower numbers indicate higher urgency. The enum is backed by lowercase string values.
| Level | Value | Severity | Description |
|---|---|---|---|
EMERGENCY |
'emergency' |
0 | System is unusable |
ALERT |
'alert' |
1 | Immediate action required |
CRITICAL |
'critical' |
2 | Critical conditions |
ERROR |
'error' |
3 | Runtime errors |
WARNING |
'warning' |
4 | Exceptional occurrences that are not errors |
NOTICE |
'notice' |
5 | Normal but significant events |
INFO |
'info' |
6 | Informational messages |
DEBUG |
'debug' |
7 | Detailed debug information |
Lower severity number = higher urgency. EMERGENCY (0) is the most urgent; DEBUG (7) is the least. When you set a minimum level, only messages with a severity number less than or equal to that level's severity are written.
Parsing Levels
Use the static parse() method to convert a string to a LogLevel case. Parsing is case-insensitive. An invalid string throws a ValueError.
<?php
use Melodic\Log\LogLevel;
$level = LogLevel::parse('warning'); // LogLevel::WARNING
$level = LogLevel::parse('WARNING'); // LogLevel::WARNING
$level = LogLevel::parse('invalid'); // throws ValueError
LoggerInterface
The Melodic\Log\LoggerInterface provides a convenience method for each severity level plus a generic log() method that accepts a LogLevel enum value.
| Method | Signature |
|---|---|
emergency() |
emergency(string $message, array $context = []): void |
alert() |
alert(string $message, array $context = []): void |
critical() |
critical(string $message, array $context = []): void |
error() |
error(string $message, array $context = []): void |
warning() |
warning(string $message, array $context = []): void |
notice() |
notice(string $message, array $context = []): void |
info() |
info(string $message, array $context = []): void |
debug() |
debug(string $message, array $context = []): void |
log() |
log(LogLevel $level, string $message, array $context = []): void |
Every convenience method delegates to log() with the appropriate LogLevel. The $context array supports placeholder interpolation and exception formatting.
FileLogger
The Melodic\Log\FileLogger is the default production logger. It writes log entries to daily-rotated files with automatic directory creation, message interpolation, and exception stack trace formatting.
Constructor
<?php
use Melodic\Log\FileLogger;
use Melodic\Log\LogLevel;
// Log everything (DEBUG and above)
$logger = new FileLogger('/var/log/myapp');
// Only log WARNING and more severe
$logger = new FileLogger('/var/log/myapp', LogLevel::WARNING);
| Parameter | Type | Default | Description |
|---|---|---|---|
$logDirectory |
string |
— | Absolute path to the directory where log files are written |
$minLevel |
LogLevel |
LogLevel::DEBUG |
Minimum severity level; messages less severe are ignored |
Daily File Rotation
Each day's log entries are written to a file named melodic-YYYY-MM-DD.log. A new file is created automatically when the date changes. There is no maximum file count — you manage cleanup externally (e.g., with logrotate or a cron job).
logs/
melodic-2026-02-15.log
melodic-2026-02-16.log
melodic-2026-02-17.log
Message Interpolation
Context values are interpolated into the log message using {placeholder} syntax. Any scalar value or object with a __toString() method can be interpolated. The exception key is reserved and skipped during interpolation.
<?php
$logger->info('User {user} logged in from {ip}', [
'user' => 'alice',
'ip' => '192.168.1.10',
]);
// Output: [2026-02-17 14:30:00] INFO: User alice logged in from 192.168.1.10
Exception Formatting
When the context array contains an exception key with a Throwable value, the logger appends the exception class, message, file/line, and full stack trace to the log entry.
<?php
try {
$this->processPayment($order);
} catch (\Throwable $e) {
$logger->error('Payment failed for order {order_id}', [
'order_id' => $order->id,
'exception' => $e,
]);
}
This produces a log entry like:
[2026-02-17 14:30:00] ERROR: Payment failed for order 1234
Exception: App\Exceptions\PaymentException
Message: Insufficient funds
At: /var/www/app/Services/PaymentService.php:47
Trace:
#0 /var/www/app/Controllers/OrderController.php(32): ...
#1 /var/www/vendor/melodicdev/framework/src/Routing/RoutingMiddleware.php(45): ...
Minimum Level Filtering
When a minimum level is set, messages with a severity number greater than the minimum are silently discarded. Severity is compared numerically: lower number = more severe.
<?php
// Only WARNING (4), ERROR (3), CRITICAL (2), ALERT (1), and EMERGENCY (0)
$logger = new FileLogger('/var/log/myapp', LogLevel::WARNING);
$logger->warning('Disk space low'); // Written (severity 4 <= 4)
$logger->error('Disk full'); // Written (severity 3 <= 4)
$logger->info('Request served'); // Ignored (severity 6 > 4)
$logger->debug('Query took 12ms'); // Ignored (severity 7 > 4)
Silent failure. The FileLogger catches all exceptions during the write operation. If the log directory is not writable or the disk is full, the logger fails silently rather than crashing your application.
Directory Auto-Creation
If the log directory does not exist when a message is written, FileLogger creates it recursively. This happens inside the same try/catch that protects against write failures.
File and directory hardening (4.0). Log output routinely carries internal detail — filesystem paths, exception messages, and full stack traces — so it is kept out of reach of other local users:
- The log directory is created
0750(was0755). - New log files are created
0640(owner read/write, group read). - Write failures are now detected via return values:
mkdir()andfile_put_contents()emit warnings rather than exceptions, so a failed directory creation or append is caught by checking their results, not by the surrounding try/catch alone.
Log-Injection Sanitization
Each log line is sanitized before it is written: carriage-return and line-feed sequences (\r\n, \r, \n) are collapsed to spaces across the whole interpolated line — not just individual context values — so attacker-controlled data embedded anywhere in the message cannot forge additional log lines. The exception block (class, message, file/line, trace) formats its own intentional multi-line output and is written separately.
<?php
// A newline injected via context (or the message) cannot spoof a second entry
$logger->warning('Login failed for {user}', [
'user' => "admin\n[2026-02-17 14:30:00] INFO: fake entry",
]);
// Output stays on one line:
// [2026-02-17 14:30:00] WARNING: Login failed for admin [2026-02-17 14:30:00] INFO: fake entry
NullLogger
The Melodic\Log\NullLogger implements LoggerInterface with all methods as no-ops. Use it in tests or when you want to disable logging entirely without changing any consuming code.
<?php
use Melodic\Log\NullLogger;
$logger = new NullLogger();
$logger->error('This goes nowhere'); // No-op
$logger->debug('Neither does this'); // No-op
Testing tip. Bind NullLogger in your test container to suppress log output during test runs while still verifying that your services accept a LoggerInterface dependency.
LoggingServiceProvider
The Melodic\Log\LoggingServiceProvider reads configuration values and registers a FileLogger singleton. It pulls the log directory and minimum level from your JSON configuration file.
<?php
use Melodic\Core\Application;
use Melodic\Log\LoggingServiceProvider;
$app = new Application(__DIR__);
$app->loadEnvironmentConfig();
$app->services(function ($container) {
(new LoggingServiceProvider())->register($container);
});
$app->run();
Configuration Keys
| Key | Type | Default | Description |
|---|---|---|---|
logging.path |
string |
{basePath}/logs |
Absolute path to the log directory |
logging.level |
string |
'debug' |
Minimum log level (case-insensitive, e.g. 'warning', 'error') |
Example config.json:
{
"logging": {
"path": "/var/log/myapp",
"level": "warning"
}
}
If logging.path is not set, the provider defaults to a logs directory inside your application's base path. If logging.level is not set, it defaults to 'debug', meaning all messages are logged.
How the Provider Works
The service provider registers a singleton factory that reads configuration at resolve time:
<?php
// Inside LoggingServiceProvider::register()
$container->singleton(LoggerInterface::class, function (Container $c) {
$config = $c->get(Configuration::class);
$app = $c->get(Application::class);
$path = $config->get('logging.path') ?? $app->getBasePath() . '/logs';
$level = LogLevel::parse($config->get('logging.level', 'debug'));
return new FileLogger($path, $level);
});
Usage in Services and Controllers
Type-hint LoggerInterface in any constructor to receive the configured logger instance.
<?php
use Melodic\Log\LoggerInterface;
use Melodic\Service\Service;
class OrderService extends Service
{
public function __construct(
private readonly LoggerInterface $logger,
) {}
public function placeOrder(array $items): Order
{
$this->logger->info('Placing order with {count} items', [
'count' => count($items),
]);
try {
$order = $this->createOrder($items);
$this->logger->info('Order {id} placed successfully', [
'id' => $order->id,
]);
return $order;
} catch (\Throwable $e) {
$this->logger->error('Order placement failed', [
'exception' => $e,
]);
throw $e;
}
}
}
Class Reference
| Class | Namespace | Purpose |
|---|---|---|
LogLevel |
Melodic\Log |
Backed enum defining eight severity levels with severity() and parse() |
LoggerInterface |
Melodic\Log |
Contract with convenience methods for each level plus generic log() |
FileLogger |
Melodic\Log |
Daily-rotating file logger with interpolation and exception formatting |
NullLogger |
Melodic\Log |
No-op logger for testing or disabling logging |
LoggingServiceProvider |
Melodic\Log |
Reads config and registers FileLogger as a singleton |