The logging capability your app uses to write structured records of what it's doing — to a file, to the system log, or nowhere at all — with level-based filtering and a CLI command to tidy things up.
ForgeLogger gives your app a small, consistent way to record what's happening. You call a logger through one contract, and a driver decides where the line actually lands — a file on disk, the system log, or a no-op that swallows everything.
Logging lives under a single interface whose methods
mirror the levels you'll most often use:
debug, info,
warning, error, and
critical — plus a generic
log() if you prefer to pass the level
yourself, and an exception() helper for
wrapping thrown errors with their trace.
Every line carries a timestamp, a level, your message, and any structured context you provide as JSON. That makes the same log readable by a human and easy to scan with tools.
Framing: this is a component of your app, not a framework subsystem. You pick the level of detail you want, choose a driver, and call the interface wherever you need to — the wiring is yours.
php forge.php package:install-module --module=forge-logger
Installation registers the logger under its contract
and wires up a sensible default configuration, so
$container->get(LoggerInterface::class)
is immediately ready to use. There's no database work
involved — logs are plain records, not tables.
You control the driver, its target, the minimum level worth recording, and optional rotation through a few environment variables:
| Variable | Default | Effect |
|---|---|---|
LOG_DRIVER (or FORGE_LOGGER_DRIVER) |
file |
Which driver writes the lines (file, syslog, null) |
LOG_PATH (or FORGE_LOGGER_PATH) |
storage/logs/forge.log |
Where the file driver appends records |
FORGE_LOGGER_MIN_LEVEL |
DEBUG |
Lowest level recorded; anything less important is dropped |
FORGE_LOGGER_MAX_FILE_SIZE |
0 |
Bytes before the current file is rotated to .1 (0 disables rotation) |
These feed the forge_logger config block.
Because FORGE_LOGGER_* mirrors the shorter
LOG_* keys, you can prefix things without
changing behavior — useful when the same environment
serves more than one purpose.
Resolve the logger through its contract and call it like any object:
use Forge\Core\Contracts\LoggerInterface;
$logger = $container->get(LoggerInterface::class);
$logger->info('Order completed');
$logger->warning('Disk nearly full', ['used' => 92]);
The generic log($message, $level,
$context) lets you pass any level by name, and
exception() records a thrown error along
with its class, file, line, and full trace:
try {
$result = $service->doSomething();
} catch (\Throwable $e) {
$logger->exception($e);
// handled
}
Each line is written as
[YYYY-MM-DD HH:MM:SS] [LEVEL] message {"context": ...},
with the context serialized as JSON when you supply
one. Any newlines inside your message are flattened to
spaces, so a single log entry always stays one line —
a small guard against log-injection tricks.
Five levels capture the importance of a record, in ascending order:
| Level | Priority | Use it for |
|---|---|---|
DEBUG |
0 | Detailed, low-noise detail you don't want in production |
INFO |
1 | Normal, useful milestones (order placed, user signed in) |
WARNING |
2 | Something odd but not fatal (high usage, retry imminent) |
ERROR |
3 | A failure happened but the app kept running |
CRITICAL |
4 | Something serious that needs attention right away |
The min_level setting is a floor: any
record below it is silently skipped before it reaches a
driver. Set it to ERROR in production and
debug/info chatter never touches disk; set it to
DEBUG locally if you want the full picture.
A driver is a tiny class implementing one method,
write($message). Three ship with the
capability:
The default. Appends each line to your
configured path, creating the directory as
needed. When max_file_size is set
and the current file grows past it, the file is
rolled over to forge.log.1 (the
previous backup is replaced) so a runaway log
can't grow without bound.
Sends each record to the operating system's
log facility via syslog() — handy
when you want your app's messages to appear
alongside everything else the server reports.
Discards everything. Useful as a safety net (if a configured driver can't be found, records fall back here rather than crashing) or when you want the logger's API without any output.
The driver set is extensible: register your own class
(anything implementing the single-method contract)
through the service's registerDriver(),
then select it with LOG_DRIVER. Your app
code keeps calling the same logger interface.
ForgeLogger ships one command:
log:clear. It removes the current log file
and any rotated backups (up to .9):
php forge.php log:clear
It reports how many files it cleared, or notes that no log files were found at the configured path. Useful before a fresh run or to reclaim space on a busy app.
The logger registers under the core
LoggerInterface, so your classes can
depend on the contract and stay independent of the
implementation. Inject it with constructor
dependencies:
use Forge\Core\Contracts\LoggerInterface;
final class OrderService
{
public function __construct(
private readonly LoggerInterface $logger,
) {}
public function complete(int $orderId): void
{
// ... place the order ...
$this->logger->info('Order completed', ['order_id' => $orderId]);
}
}
Pass structured context as the second argument to keep records searchable — user ids, record ids, or any values you'll want to correlate later. Whether you log to a file, the system log, or a custom driver is a configuration concern, separate from the calls you make in your code.