jackiedo/log-reader
A Laravel package for reading and browsing application log files from PHP. It parses log entries, filters by level, paginates results, and makes it easy to surface logs in admin panels or dashboards without leaving your app.
composer require jackiedo/log-reader
php artisan vendor:publish --provider="JackieDo\LogReader\LogReaderServiceProvider"
Publishing gives you config/log-reader.php. The keys you actually care about are log_path (defaults to storage_path('logs')), the file extension/pattern used to glob the directory, and the date format used when parsing entry headers. If you use daily channels you normally don't need to change anything; if you write to a non-standard directory or use a custom channel, point log_path/pattern at it. Clear config cache after touching it (php artisan config:clear), because this is one of those packages where a stale cached config silently reads the wrong directory.
Check what Composer actually resolved before you build on it: 2.3.0 is the first release to support Laravel 10.x, and the illuminate/* constraint still stops short of Laravel 11/12 — so on Laravel 10 you want ^2.3, and on 11+ you need a fork or a constraint bump (see Gotchas).
Look first at the facade (JackieDo\LogReader\Facades\LogReader) and the two value objects it returns: LogFile and LogEntry. Everything is collection-driven, so once you have entries you're back in familiar Laravel territory (->take(), ->count(), ->groupBy('level')).
The first use case is triage in Tinker:
use JackieDo\LogReader\Facades\LogReader;
LogReader::getLogs()->each(fn ($file) => dump($file->fileName, $file->size));
LogReader::filterByLevel('error')
->filterByDate(now()->subDay()->toDateString())
->getEntries()
->take(5)
->each(fn ($e) => dump($e->date, $e->header));
If that snippet works you have a working parser; everything else is presentation.
Wrap it in your own service, not the facade. The package is a parsing library, not an application layer. Inject it behind a small class that returns arrays/DTOs so you can swap to opcodesio/log-viewer or a custom parser later without touching controllers:
class LogQueryService
{
public function recentErrors(int $limit = 50): Collection
{
return LogReader::filterByLevel('error')
->getEntries()
->sortByDesc('date')
->take($limit)
->values();
}
}
Admin panel log page. Build your own controller + Blade view rather than expecting a shipped UI. Read query params (level, date, keyword, file), pass them to the fluent filters, paginate the resulting collection manually (new LengthAwarePaginator($entries->forPage($page, 25), $entries->count(), 25)). The package's per_page-style config only helps if you honor it yourself.
Granular retention instead of log:clear. This is the package's best day-to-day win: it can delete by level and date, so you can keep warning/info for a week but only keep error for a month. Schedule it:
$schedule->call(fn () => LogReader::deleteLogByDate(now()->subDays(30)->toDateString()))
->daily();
Ops dashboard / support bundle. A read-only JSON endpoint (/admin/logs?level=error&q=Stripe) that returns id, date, level, header is trivial and gives support staff something better than SSH. Extend to a "download filtered logs" action that streams the matched entries as text/plain.
Health checks and alerting. A scheduled job that counts error entries since the last run and posts to Slack is more useful than parsing laravel.log with grep in a shell script, and it survives log rotation because it re-globs each run.
Keyword search is your entry point for incident work. filterByKeyword('tenant_id=42') across the whole directory is the fastest way to correlate a customer complaint with a stack trace, and it's the thing you'll reach for weekly.
Compatibility ceiling moved, but it's still a ceiling. 2.3.0 adds Laravel 10.x support (PR #47) — that's the first time the package tracks a current framework in over a year, so pin ^2.3 deliberately. Laravel 11/12 remain unsupported by the illuminate/* constraint; expect to need --with-all-dependencies, a fork, or a constraint bump. With zero dependents, you are the maintainer if it breaks — and upgrading a Laravel 9 project to 10 is now the moment to confirm you get 2.3.0 rather than an old cached resolution.
Alpine/Docker images were silently broken before 2.3.0. The package used GLOB_BRACE, which musl libc (Alpine) does not define, so globbing failed — usually as empty results rather than an exception. 2.3.0 fixes this (Issue #46), which means containerized deployments that previously "worked" only on Debian-based images can now adopt it. If you're stuck on a pre-2.3.0 version on Alpine, either upgrade or avoid brace patterns in your config; don't debug the parser when the real problem is the libc.
It only understands the default Monolog line format. [timestamp] env.LEVEL: message {context} plus indented stack lines. Switch a channel to JsonFormatter, or add a custom formatter, and entries parse as garbage or vanish. Verify by dumping a single entry's fields before trusting any filter.
Globbing is single-directory. The pattern is applied inside log_path; nested channel directories (storage/logs/payments/…) need separate configuration or a pattern that matches them — and if you rely on brace patterns to cover multiple extensions, that's exactly the path the Alpine bug used to eat. Silent empty results almost always mean the path/pattern is wrong, not the parser.
Entry IDs are positional and shift after deletions. Never persist an id in a database or a support ticket; it's a handle for the current read only.
getEntries() parses everything before your take() runs. Filtering and limiting after the fact doesn't save parse time. On multi-hundred-MB logs this is slow — scope by file first (getLogs() → pick the file → entries), and cache counts with Cache::remember rather than recomputing on every dashboard refresh.
Deletion races with rotation. Deleting by date or level rewrites the file in place; if daily rotation fires mid-operation you can lose entries from the new file or leave a partial write. Run pruning jobs at a time offset from rotation, and consider a mutex (Cache::lock).
Timezone is naive. Entry dates are parsed strings from the log header, not Carbon instances normalized to app.timezone. If your server clock and app timezone differ, filterByDate(now()->toDateString()) will silently miss or include the wrong day.
Don't expose it unauthenticated. Logs routinely contain tokens, emails, and full request payloads. Put any log route behind auth plus an explicit gate/ability, and never let the keyword filter be reflected into the HTML unescaped.
Filesystem permissions. Reads and deletes happen as the PHP process user; a web request that can read but not write will throw on the retention page. Test the delete path as the same user that runs the scheduler.
Escape hatches. If you hit a limit, the two extension points are swapping the facade binding in a service provider (so your wrapper can delegate to a different reader) and preprocessing the raw file yourself before handing a trimmed copy to the parser. Both are cheaper than patching the vendor tree.
How can I help you explore Laravel packages today?