lexik/monolog-browser-bundle
Symfony bundle that provides a web interface for browsing, filtering, and searching Monolog logs. Inspect entries by channel, level, and date, view stack traces, and page through results without digging through raw log files.
lexik/monolog-browser-bundle gives you two things: a Monolog handler that writes log records to a Doctrine DBAL table, and a small Symfony web UI to filter and read them. It is most useful when you need cross-request, cross-user logs on a shared dev or staging box — the cases where tail -f var/log/dev.log stops being practical. It is not a production observability tool, and with a last release in 2016, 59 stars, and essentially no dependents, treat it as legacy software. Spike it against your Symfony/PHP version before committing.
Minimal setup for a Symfony 2/3-era app:
composer require --dev lexik/monolog-browser-bundle. On Symfony 4+ you will also need to register the bundle in config/bundles.php; on Symfony 2/3 add it to AppKernel./monolog, from the bundle's routing file. The UI lives at that prefix.Resources/ for the shipped SQL/schema, or run php bin/console list lexik to discover any table-create/purge commands. Add indexes on created_at, level, and channel immediately — the default schema is minimal and the UI filters on those columns.DependencyInjection/Configuration.php: table name, allowed environments, base layout/template overrides, and route/UI settings. Restrict allowed_environments to dev/staging (this is a visibility guard, not authentication).Resources/config/services.xml (commonly documented as lexik_monolog_browser.monolog.handler). Then use Monolog's type: service:monolog:
handlers:
monolog_browser:
type: service
id: lexik_monolog_browser.monolog.handler # verify in services.xml
level: debug
channels: ["!doctrine", "!event"]
/monolog, reproduce a problem, and filter by channel, level, message, and date range. That is the first real use case: instead of grepping one log file, you see what the application logged across requests, with context and stack traces attached.Where to look first when something does not behave: Handler/ for how records are inserted and serialized, Resources/config/services.xml for service IDs and constructor arguments, DependencyInjection/Configuration.php for every option, Resources/views/ for the UI and template override points, and Command/ for purge/schema commands.
Run it alongside file logs, not instead of them. The DBAL handler is synchronous and one INSERT per record. Keep your normal fingers_crossed/stream handler for the full log and add the browser handler as a secondary debug/staging sink:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: file
file:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
db:
type: whatfailuregroup
members:
monolog_browser:
type: service
id: lexik_monolog_browser.monolog.handler
level: warning
channels: ["!doctrine", "!event"]
Wrapping the DB handler in whatfailuregroup is worth doing: if the log database is down or the insert fails, you do not want a logging failure to become the exception that breaks the request or masks the original one.
Use channels deliberately. The UI is only as useful as your channel names. Define app-level channels (app, billing, import, api) and filter by them. Exclude doctrine, event, and often security from the DB handler; otherwise ORM SQL noise and security chatter drown out the signal and can trigger the recursion problem described below.
Add request correlation via a processor. The UI may not expose arbitrary extra filters, but the data is still in the table. A small Monolog processor that adds a request ID, route, and user identifier turns direct SQL into a per-request trace viewer:
final class RequestContextProcessor
{
public function __invoke(array $record): array
{
$record['extra']['request_id'] = $_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(8));
$record['extra']['route'] = $_SERVER['REQUEST_URI'] ?? 'cli';
// add user id from your security context if available
return $record;
}
}
Then, when the UI shows one interesting error, query all records for that request ID directly:
SELECT created_at, channel, level, message, context, extra
FROM monolog_entries
WHERE JSON_EXTRACT(extra, '$.request_id') = ?
ORDER BY created_at;
Even if extra is a serialized blob rather than native JSON, the pattern holds: the database is the real interface, the UI is a convenience.
Treat the table as queryable data. Beyond the built-in browser, use it for daily error-rate checks, rough alerting, or a team dashboard:
SELECT channel, level, COUNT(*) AS hits
FROM monolog_entries
WHERE created_at > NOW() - INTERVAL 1 DAY
GROUP BY channel, level
ORDER BY hits DESC;
You can fingerprint messages (strip IDs, paths, numbers) in SQL or a small script to find regressions after deploys.
Schedule retention. The bundle ships or documents a purge/clear command; discover the exact name with bin/console list lexik rather than copying old blog posts. Run it from cron, or do a direct DELETE FROM ... WHERE created_at < .... Without retention, a busy staging environment fills the table and the UI becomes slow. Keep days, not weeks, in dev; shorter in staging; and vacuum/optimize periodically.
Put the UI behind real access control. allowed_environments merely disables routes outside listed environments. Add a security firewall rule, an IP allowlist, basic auth at the reverse proxy, or a VPN requirement. Logs frequently contain SQL, stack traces with absolute paths, user data, and sometimes credentials in context.
Embed the UI in your admin theme. If the default page does not match your admin layout, use the bundle's base_layout option or override the Twig templates. On Symfony 3.4+/4+ override under templates/bundles/LexikMonologBrowserBundle/; on older apps use app/Resources/LexikMonologBrowserBundle/views/. This is usually a copy-and-adjust of the layout, not a rewrite.
Disable it in tests and CI. Add a test environment Monolog config that omits the DB handler, or point it to a dedicated test database. Otherwise every test run writes noise into the shared table and can slow the suite. If you do test against it, truncate the table in setUp and use a separate connection so transaction rollbacks do not hide log rows.
DBALException, old Statement::execute signatures, dynamic properties), Doctrine DBAL 3 (fetchAll → fetchAllAssociative, execute changes), Monolog 2/3 handler interfaces, and Symfony 5/6 (removed base controller, annotation routing, kernel changes). Pin old versions, fork it, or budget time to copy the handler into your own code — it is small.doctrine channel, every log INSERT is itself logged, producing another INSERT. The table grows absurdly fast. Always exclude doctrine (channels: ["!doctrine"]) and verify by watching row counts after a single request.whatfailuregroup or make the handler swallow exceptions and continue. This matters most exactly when you need logs: during outages.debug in a busy environment, the handler becomes a performance problem and a write hotspot. Log warning or higher in shared environments, or accept the cost only for low-traffic dev.created_at, level, and channel; ideally a composite index for date + level queries. The UI's message search is usually a LIKE '%...%' scan, which does not use a normal index and gets slow quickly.TEXT limits or max_allowed_packet, and can fail serialization. Add normalizers/processors that truncate strings and convert objects to scalars. Never log secrets or full PII; both DB access and the UI expose it.json_encode fails on invalid UTF-8, resources, recursion, and closures. If entries disappear, check whether the handler is serializing context/extra and add a normalizer.type: service wiring can ignore Monolog options. Depending on the MonologBundle version, level, channels, and formatter may not be applied the way they are for built-in handler types, or may be applied by wrapping the service. Inspect the compiled container with debug:container and set critical options inside the service definition if needed.allowed_environments includes the current kernel.environment, the routes are imported with the expected prefix, and the cache is cleared.doctrine:schema:update --force may try to drop or ignore the log table. Keep the table outside your normal entity schema workflow, or explicitly exclude it.created_at may be written in PHP's timezone while the DB stores something else; date filters in the UI can be off by hours. Standardize on UTC at write time and convert only for display.level, channels, bubble, and whether an outer fingers_crossed or buffer handler never flushed. Confirm the record reaches Monolog at all before debugging the DB insert, then check the DB general log for the INSERT.How can I help you explore Laravel packages today?