cesargb/php-log-rotation
A framework-agnostic PHP library for rotating log files by size or time, with automatic gzip/bzip2 compression, retention limits, and cleanup of old logs. Works standalone or with Laravel, PSR-3 loggers, and custom handlers.
Install with Composer:
composer require cesargb/php-log-rotation
The package is a single, dependency-free class. Your first stop should be the README example and src/LogRotate.php — the public fluent API (files(), size(), compress(), dateFolder(), truncate(), rotate()) is the whole surface area, so you can read it end-to-end in a few minutes.
Minimal working rotation:
use CesarGB\LogRotation\LogRotate;
$rotator = (new LogRotate('/var/log/app/app.log'))
->files(7) // keep the 7 most recent rotations
->size(10240) // rotate when the active file exceeds this size (KB)
->compress() // gzip rotated files
->rotate(); // bool: did this run actually rotate?
The intended first use case is a scheduled job (cron, systemd timer, or a Laravel scheduled command) that calls rotate() once per hour/day. The class does not run a daemon and does not self-schedule — it is a one-shot imperative call that you invoke from your own scheduler.
Cron / Laravel scheduler wrapper. Keep the paths in config, not in the scheduler closure:
// app/Console/Kernel.php
$schedule->call(function () {
foreach (config('logging.rotate') as $path => $opts) {
(new LogRotate(storage_path("logs/{$path}")))
->files($opts['keep'])
->size($opts['size'])
->compress($opts['gzip'])
->rotate();
}
})->hourly();
Size + retention together. size() is the trigger, files() is the cap. Use both: without size() you either rotate every run (too many tiny files) or only on a manual call; without files() you accumulate rotations forever. Add minSize() where available so idle logs don't produce empty rotations on every scheduled tick.
Long-running writers — use truncate(). If a process (PHP-FPM, a daemon, supervisor-managed worker) holds the log file open, a normal rename-based rotation means the process keeps writing to the renamed file and the new path stays empty. ->truncate() truncates in place, preserving the inode, so the open handle keeps pointing at the same file. This is the correct mode for anything that isn't reopened after rotation.
Custom naming / dated folders. ->dateFolder('Y-m-d') groups rotations into subdirectories, which is convenient for per-day retention and for humans grepping old logs. Combine with compress() to keep the tree small. If you need a specific rotated filename, use the custom-filename option rather than post-processing.
Multi-log rotation. The class is per-file, so loop over a list. Wrap that loop in a small service (LogRotationService::rotateAll()) so you can unit-test the globbing/path logic without touching the real class.
Integration with Monolog. This is not a Monolog handler; it lives one level below. The clean integration is a small Monolog HandlerInterface wrapper that calls rotate() on close(), or simply a scheduled command if you use StreamHandler. Don't try to force it into the logging hot path — rotation is an out-of-band concern.
size() units. Historically this is KB, not bytes. A value of 1024 means ~1 MB, not 1 KB. Verify against the version you install — this is the single most common "my logs got rotated at the wrong time" bug.lsof /path/to.log. Fix with truncate() or by restarting the writer after rotation.rotate() calls or with the writer. Two overlapping cron runs (or a slow compression step overlapping the next tick) can race. Guard with a lock file or withoutOverlapping() in the scheduler.compress() uses gzopen/gzwrite; on stripped-down PHP builds or Docker images without zlib you'll get errors or silent skips. Test in the same image you deploy.rotate() returns bool. Use it to log/metric whether rotation actually happened — this is your best signal that size thresholds are tuned correctly.rotate() in a tight loop for the same file.files() cleanup traverses dated subdirectories in your version — retention counting semantics differ between flat and folder modes, and it's easy to end up with old dated folders that are never pruned.composer.json and check the CHANGELOG before upgrading; examples online frequently mix 2.x and 3.x syntax.sys_get_temp_dir() in tests and assert on the returned bool plus the files spawned — much cheaper than asserting on real rotated logs.truncate() and a writer that flushes per line.How can I help you explore Laravel packages today?