danielme85/laravel-log-to-db
Laravel Log to DB adds a Monolog handler that writes application logs to a database table instead of files. Configure levels, channels, and a custom model, then query or browse logs with Eloquent for easier searching and monitoring.
composer require danielme85/laravel-log-to-db
php artisan vendor:publish --provider="Danielme85\LogToDb\LogToDbServiceProvider"
php artisan migrate
Publishing gives you two things: config/logtodb.php (connection, table/collection, retention, queue behaviour) and the migration that creates the log_events table. Read the config file first — it is the whole API surface of the package. Then open config/logging.php and register a channel:
'channels' => [
'logtodb' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => \Danielme85\LogToDb\LogToDbHandler::class,
],
],
First useful use case: point your production stack channel at both daily and logtodb, so files stay as the durable fallback while the DB copy becomes greppable from any machine, an admin UI, or a php artisan tinker session:
'stack' => [
'driver' => 'stack',
'channels' => ['daily', 'logtodb'],
],
Verify with Log::channel('logtodb')->info('hello', ['foo' => 'bar']); and then select * from log_events order by id desc limit 5;. If nothing lands, check storage/logs/laravel.log for a handler exception before touching the package config — Monolog swallows handler errors into the fallback channel.
Isolate writes to their own connection. Logging is bursty and should never compete with your app queries for connections or slow down a web request. Add a logging connection in config/database.php pointing at a separate host/database, and set it in config/logtodb.php. This also makes retention trivial: you can truncate or drop partitions without touching application data.
Queue the writes for anything above low traffic. The synchronous path adds an INSERT to every request that logs. Enable the queue option in logtodb.php and point it at a dedicated logs queue so a backlog of log jobs never starves real work. Pair it with a supervisor worker: php artisan queue:work --queue=logs.
Use a stack to split by level. Keep debug/info in files and only persist warning and above to the DB. Two channels, two levels, one stack:
'channels' => [
'db_errors' => [
'driver' => 'monolog',
'level' => 'warning',
'handler' => \Danielme85\LogToDb\LogToDbHandler::class,
],
],
Enrich records with context, not string concatenation. The handler persists Monolog's context and extra arrays as JSON. Use Log::withContext(['order_id' => $id, 'tenant' => $tenant]) once per request/job, then log freely — every record carries the correlation fields and you can query them later. This is the single biggest day-to-day win over file logs.
Query it like data, not like text. Write a small read-side model or repository around the table:
LogEvent::where('level_name', 'ERROR')
->where('datetime', '>=', now()->subHour())
->where('context->tenant', $tenant)
->latest('datetime')
->paginate(50);
Use this for a support dashboard, an alerting cron, or a Slack notifier — far cheaper than parsing log files.
Retention from day one. Add a scheduled command that deletes rows older than N days (LogEvent::where('datetime', '<', now()->subDays(30))->delete() in chunks) and index datetime and level_name. Without this, the table becomes the largest thing in your database within months.
Wrap it for testability. Don't call Log::channel('logtodb') scattered through the codebase. Log through the default channel and configure the stack per environment; in phpunit.xml set LOG_CHANNEL=null or single, and use Log::spy() in tests rather than asserting on database rows.
daily in the stack before the DB channel so there is a durable copy; ordering matters.failed_jobs table. Add an alert on failed jobs in the logs queue, or you'll discover the gap during an incident.context and extra go through json_encode. Objects, closures, resources, and Eloquent models with circular relations will either throw or be silently lossy. Pass scalars and arrays; convert models to ['id' => $model->id] explicitly.level and the package's own configured level both apply. If records vanish, check both — the more restrictive wins, and it's a common source of "logging is broken" confusion.logtodb.php or logging.php, run php artisan config:cache. Stale cached config is the usual reason a new channel appears to be ignored.mongodb/laravel-mongodb package installed and configured, plus type => 'mongodb' and a collection instead of a table. Don't assume the migration applies there — it doesn't.user_id and IP are only populated when there is an authenticated request. Artisan commands, queue workers, and scheduled tasks log null there — inject the actor yourself via withContext if you need traceability.Log::error($e->getMessage(), ['exception_class' => get_class($e)]).shouldHandle override for rate limiting — cleaner than post-processing the table.composer.json and re-test the handler after Laravel major upgrades; the Monolog handler signature and Laravel's monolog driver wiring have changed across versions.How can I help you explore Laravel packages today?