Log Viewer
A maintainer-only admin section for reading, searching, and deleting Laravel log files in storage/logs/. It is shipped as a self-contained domain module and does not require any new dependency.
Authorization
The whole module is gated on the system_admin role only — no entry exists in config/permission-resources.php. Non-system-admin users get a 403 on the route and never see the menu item, so the feature is invisible to them.
The route file routes/web/log-route.php is listed in the $routesWithoutPermissionMiddleware array in routes/web.php, which is why the dynamic check.permission middleware is skipped for this group.
Routes
The route file is loaded inside the authenticated web group. Named routes:
logs.index—GET /logs(Inertia)logs.dtApi—GET /logs/dt(JSON datatable feed)logs.show—GET /logs/{filename}(Inertia)logs.entries—GET /logs/{filename}/entries(JSON, paginated)logs.destroy—DELETE /logs(JSON, bulk delete)
Filename route parameters use where('filename', '[A-Za-z0-9._-]+\.log') so path traversal and non-.log requests never reach the controller.
UI
Two Inertia pages live under resources/js/pages/Admin/Logs/:
Index.vue
SkDatatable over logs.dtApi. Columns:
name— filename (sortable, search by substring)channel_type—daily(matcheslaravel-YYYY-MM-DD.log),single(laravel.log), orothersize_bytes— formatted as KB / MB / GBmodified_at— relative timestamp with absolute tooltipis_active— chip when the file is the live daily file or written within the last 5 seconds
Row action: Delete (disabled when is_active). Bulk select supports the Delete selected toolbar action; both flow through the logs.destroy endpoint and are gated by useConfirm.
Show.vue
Filter panel + paginated viewer for a single file. Filters:
levels[]— multi-select from the eight Laravel/PSR-3 levels (emergency,alert,critical,error,warning,notice,info,debug)from,to— ISO date rangekeyword— case-insensitive substring search across message + stack trace
Entries are listed newest first. Filter changes call logs.entries via useApi, replace the list, and reset the cursor. Load more uses next_cursor from the previous response to append the next older page. EOF flag short-circuits the button.
Each entry collapses to a level chip + timestamp + first part of the message. Expanding shows the full message, JSON-pretty-printed context (if any), and the stack trace.
Domain layer
The Logs domain is vendor-resident — its runtime layer runs from the package (src/Domain/Logs/, namespace Lvntr\StarterKit\Domain\Logs\) and is not scaffolded into your app. App\Domain\Logs\... imports keep working through class_alias.
src/Domain/Logs/ (Lvntr\StarterKit\Domain\Logs\)
├── DTOs/
│ ├── LogFileDTO.php # name, path, size_bytes, modified_at, channel_type, is_active
│ ├── LogEntryDTO.php # timestamp, level, env, message, context, stack, is_raw
│ ├── LogEntryFilterDTO.php # levels, from, to, keyword, cursor, per_page
│ └── DeleteLogFilesDTO.php # filenames[]
├── Queries/
│ ├── LogFileQuery.php # lists storage/logs/, filters/sorts/paginates in-memory
│ └── LogEntryQuery.php # streams a single file with cursor pagination
├── Actions/
│ └── DeleteLogFilesAction.php # bulk delete with active-file guardrails
├── Events/
│ └── LogFilesDeleted.php # carries deleted filenames[] + causer id
├── Listeners/
│ └── LogActivityForLogFilesDeleted.php # writes one spatie/activitylog entry per batch
└── Services/
└── LaravelLogParser.php # stateless line parser; multiline stack-trace aware
LogFilesDeleted → LogActivityForLogFilesDeleted is wired in StarterKitServiceProvider::registerEventListeners() (vendor), with both sides bound to the vendor FQCN so the dispatched event matches the registration key.
Streaming entry reader
LogEntryQuery::paginate() opens the file with fopen('rb') and walks lines with fgets() capped at 64KB per line. Because a log file grows at its end, a page is built backwards: the window ends at cursor (exclusive) and expands towards the start of the file in 64KB steps until it holds a full page, capped at 2MB per request. The window is then parsed forward (entries can only be read in write order) and emitted reversed. The cursor is the byte offset where the page's OLDEST entry begins, so the next page resumes with a single fseek. Memory stays bounded regardless of file size.
Unmatched lines are handled by position:
- after a header — appended to the current entry's
stackfield, keeping multi-line exceptions intact - before any header (or in a file with no Laravel-format headers at all) — buffered and emitted as a single raw
LogEntryDTO(is_raw = true,level = 'raw', sentinel epoch-0 timestamp). The UI hides the timestamp for raw entries and displays them with a gray chip, so the content stays visible instead of being silently dropped. Raw entries are filtered out the moment any structured filter (level / from / to / keyword) is applied.
Active-file guardrail
A file is considered "active" — and refused for deletion — when either is true:
- it is today's daily file (
laravel-{today}.log), or - its
mtimeis within the last 5 seconds (some other channel is currently appending).
Active files are reported back as failed[] entries with reason: 'active_file_protected' so partial bulk deletes still succeed for the rest.
Path-traversal guardrail
The safe filename regex ^[A-Za-z0-9._-]+\.log$ is enforced in three places: the route parameter constraint, the DeleteLogFilesRequest validation, and the DeleteLogFilesAction itself (defence in depth). Anything else returns log.invalid_filename.
Activity logging
When a delete batch succeeds (any deleted[] entries), LogFilesDeleted is dispatched. The listener writes one spatie/activitylog entry with log_name = 'system', no subject, the deleted filenames in properties.filenames, and the current user as causer. Entries surface in the existing Admin → Activity Logs page automatically.
i18n
All strings live in lang/en/sk-log.php and lang/tr/sk-log.php. The menu key (sk-menu.logs) is in the existing menu translation files. Failure reason codes returned by the action (invalid_filename, not_found, active_file_protected, delete_failed) map one-to-one onto sk-log.reason_* keys in the UI.
Wayfinder
Routes are typed: import logs from '@/routes/logs' exposes logs.index.url(), logs.show.url({ filename }), and so on. Frontend never hardcodes URLs.
Out of scope (v1)
Not implemented; open follow-up issues if you need them:
- live tail / WebSocket streaming
- cross-file search across all logs
- time-based bulk purge ("delete files older than N days") — the daily channel handles this for you
.zipexport / download- non-Laravel log formats (Apache, JSON channels)
- per-user role gating beyond
system_admin