Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Monolog Mysql Laravel Package

wazaari/monolog-mysql

Monolog handler that writes Laravel and PHP log records straight to MySQL. Ships with a ready-made schema, configurable table and connection, buffered bulk inserts for speed, and level filtering. Drops into Laravel's logging stack with minimal setup.

View on GitHub
Deep Wiki
Context7

Getting Started

composer require wazaari/monolog-mysql

Before wiring anything, open vendor/wazaari/monolog-mysql/src/MySQLHandler.php. The whole package is essentially one class, and you need three facts from it:

  1. The constructor signature (typically ($table, PDO $pdo, $initialize = true)) — the third flag makes the handler run CREATE TABLE IF NOT EXISTS on construction.
  2. The write() signature — write(array $record) means Monolog 1/2. Monolog 3 changed this to write(LogRecord $record); the last release predates Monolog 3, so confirm with composer show monolog/monolog before you assume it works on your Laravel version.
  3. isHandling() — if it is a stub returning true, per-level filtering in config/logging.php is silently ignored and you'll store every debug line.

The correct Laravel wiring is a custom channel, not handler/handler_with in config/logging.php (that path forces you to resolve a PDO inside a config file, which connects to the database during config:cache, migrate, and every Artisan command that has no business touching MySQL).

// config/logging.php
'mysql' => [
    'driver'   => 'mysql',
    'table'    => env('LOG_DB_TABLE', 'logs'),
    'connection' => env('LOG_DB_CONNECTION', 'logs'),
    'level'    => env('LOG_DB_LEVEL', 'warning'),
],
// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Log;
use Monolog\Logger as MonologLogger;
use Wazaari\MonologMySql\MySQLHandler;
use Monolog\Handler\WhatFailureGroupHandler;

public function boot(): void
{
    Log::extend('mysql', function (array $config) {
        $handler = new MySQLHandler(
            $config['table'],
            DB::connection($config['connection'])->getPdo(),
            false // schema managed by a migration, not by the handler
        );

        // Never let a logging failure take down the request.
        return new MonologLogger('mysql', [new WhatFailureGroupHandler([$handler])]);
    });
}

Then use a separate database connection named logs in config/database.php. This is the single most important setup decision — it decouples log writes from your application's transaction lifecycle (see Gotchas).

First use case: dump production warning+ into a queryable table so support can SELECT recent errors without SSH-ing into a box and grep-ing rotated files. Ship that, verify rows land with SELECT * FROM logs ORDER BY id DESC LIMIT 5, and only then expand.

Implementation Patterns

Stack it with a fallback. Don't make MySQL your only sink. Put it in a stack with stderr/single so you have a second copy when the DB is unavailable:

'stack' => [
    'driver' => 'stack',
    'channels' => ['daily', 'mysql'],
    'ignore_exceptions' => true,
],

Because mysql carries 'level' => 'warning', the stack is cheap: info/debug goes to files, exceptions and warnings go to both. Note the stack writes each qualifying record twice — acceptable for errors, not for debug.

Buffer to cut round-trips. One INSERT per log line is fine at low volume and pathological under a noisy loop. Wrap in a FingersCrossedHandler so nothing is written until an error occurs in the request, then the buffered context is flushed along with it:

new FingersCrossedHandler(new WhatFailureGroupHandler([$mysql]), new ErrorLevelActivationStrategy(MonologLevel::Warning))

That gives you the "what happened in the 200 lines before the crash" narrative — the main reason people want a DB log in the first place.

Enrich records with a processor instead of a custom formatter. The handler stores extra (as JSON), so adding a processor keeps the package untouched:

$logger->pushProcessor(function ($record) {
    $record['extra']['request_id'] = app('request_id');
    $record['extra']['user_id'] = auth()->id();
    $record['extra']['tenant_id'] = tenant('id');
    return $record;
});

This is also how you get multi-tenancy, since there's no tenant column out of the box.

Manage the schema yourself. Run the handler once against a scratch database with the default initialize = true, then SHOW CREATE TABLE logs, port the DDL into a Laravel migration on the logs connection, and pass false from then on. Add what the package won't: INDEX (channel), INDEX (level), INDEX (time), and a generated created_at DATETIME for FROM_UNIXTIME(time) so you can use Carbon instead of UNIX_TIMESTAMP() everywhere.

Read side. There's no Eloquent model — write a read-only one:

class LogEntry extends Model
{
    protected $connection = 'logs';
    protected $table = 'logs';
    public $timestamps = false;
    protected $casts = ['context' => 'array', 'extra' => 'array'];
    protected $guarded = [];
}

Remember level is Monolog's integer constant (100–600), not a PSR string. Map it in a cast or query with Logger::getLevelName() semantics; don't try where('level', 'error').

Prune on a schedule. The table grows monotonically and is never rotated. A nightly job deleting in chunks (not one giant DELETE, which will lock and bloat):

LogEntry::where('time', '<', now()->subDays(14)->timestamp)
    ->orderBy('id')->chunkById(5000, fn ($rows) => LogEntry::whereIn('id', $rows->pluck('id'))->delete());

Tests. Override the channel in phpunit.xml (LOG_CHANNEL=null or array) so the suite doesn't hammer the log DB or trip over the CREATE TABLE. If you must assert on rows, point LOG_DB_CONNECTION at your test connection and use RefreshDatabase.

Gotchas and Tips

Monolog version mismatch is the first thing to check. The package's final release is Nov 2021. If your Laravel pulls Monolog 3, write(array $record) will throw a type error the moment a log fires — and because that happens inside the log path, you'll usually see it as a confusing cascading failure rather than a clean message. Pin monolog/monolog:^2.0 if your framework allows it, or fork the class and change the signature to write(LogRecord $record); it's a ten-line patch.

Transactions will eat your logs. If the handler shares a connection with your business queries and you log a critical error inside a transaction that then rolls back, the log row is rolled back with it — exactly when you need it most. Use a separate logs connection. This is the number-one reason people conclude "the handler is broken."

DDL on every bootstrap. With initialize = true, the handler executes CREATE TABLE IF NOT EXISTS on construction — a wasted round-trip per request, and it requires CREATE privileges for your application user, which you probably don't want in production. Turn it off and own the schema.

A failing database takes the logger down with it. DB::connection(...)->getPdo() connects eagerly at channel creation, so a DB outage throws during logging, inside the exception handler, possible recursion. Wrap the handler in WhatFailureGroupHandler, and guard the channel closure with a try/catch that falls back to stderr — otherwise your 500 page becomes a white page.

Don't interpolate the table name from user input. The table is concatenated into INSERT/CREATE SQL and almost certainly isn't quoted or whitelisted. Keep it a hard-coded config constant.

JSON-encoding context bites twice. Passing an Eloquent model with loaded relations into a log context can serialize megabytes (or blow up on a closure/resource). And the handler does no redaction — Log::error('failed', $request->all()) will happily persist passwords, tokens, and PII into a table that outlives the request. Use a processor that scrubs password, token, authorization keys before the handler sees them.

Filtering may not work. If isHandling() is a stub, the level key in your channel config does nothing and you'll fill the table with debug noise. Verify with a one-line Log::debug() on a channel configured for error.

Octane and long-running workers. The PDO instance is resolved once and held; a recycled or timed-out connection means silent write failures until the worker restarts. Set PDO::ATTR_ERRMODE => ERRMODE_EXCEPTION so failures surface instead of being swallowed by execute() returning false, and consider resolving the handler per-request if you use Octane.

Weigh fork vs. dependency. 139 stars, zero dependents, one release since 2021, one class. That's not a knock on the code — it's a 100-line handler — but it means you own the maintenance. If Monolog 3 support or a schema tweak is the only thing standing between you and shipping, copy the class into app/Logging/, add the columns you actually want (level_name, created_at, request_id as real columns rather than JSON), and drop the dependency. Given the package's small surface, the fork is usually cheaper than the workaround.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin