A Symfony bundle providing a single, reusable Logger service that wraps PSR-3 Monolog channels with two things: structured context tagging (every log call is tied to a \BackedEnum case, e.g. OrderContext::PaymentFailed) and pluggable parameter serialization (non-scalar log parameters —...
php artisan boost:add-skill ecourty/logger-bundle
Save this content to: AGENTS.md
# AGENTS.md - Coding Guidelines for AI Agents
## 🎯 Core Concept
A Symfony bundle providing a single, reusable `Logger` service that wraps PSR-3 Monolog
channels with two things: **structured context tagging** (every log call is tied to a
`\BackedEnum` case, e.g. `OrderContext::PaymentFailed`) and **pluggable parameter
serialization** (non-scalar log parameters — exceptions, domain objects — are converted to a
loggable shape by the first matching processor, instead of relying on Monolog's generic
object normalization).
### Problem Solved
Apps that log through the raw `Psr\Log\LoggerInterface` tend to end up with unstructured
messages, hardcoded channel and prefix logic scattered across the codebase, and ad hoc
serialization of exceptions or domain objects repeated at every call site. This bundle
centralizes that as a single reusable service, with the project-specific bits (channel name,
message prefix) exposed as configuration instead of hardcoded.
### Solution
- `Logger` (`src/Service/Logger.php`): a final service with the full 8-level PSR-3 interface
(`emergency` → `debug`). Every method takes a `\BackedEnum $context` (any backed enum —
no bundle-specific marker interface to implement), a `string $message`, and an
`array $parameters`. It formats the message as `"{$context->value} - {$message}"` and
runs every parameter through the first tagged processor whose `supports()` returns true.
- `LogParameterProcessorInterface` (`src/Contract/`): the extensibility contract. Any class
implementing it — in this bundle or in a consuming project — is auto-registered and
auto-injected into `Logger`, no service configuration needed. Tagging is wired
imperatively by `LoggerExtension::load()` via `$container->registerForAutoconfiguration()`,
**not** by a `#[AutoconfigureTag]` attribute on the interface — that attribute only
self-applies when the *consuming app's own* resource-scanned service discovery happens to
walk the file declaring it, which never happens for a file living in `vendor/`. Do not
"simplify" this back to an attribute; it silently breaks processor autowiring for every
consuming project's own custom processors (see `tests/Functional/LoggerFunctionalTest.php::testACustomProcessorRegisteredOutsideTheBundleIsAutowired`,
which is designed to fail if this regresses).
- `ThrowableLogParameterProcessor` (`src/Processor/`): the only built-in processor, serializing
`\Throwable` (recursively, including `getPrevious()`). Registered by `LoggerExtension`
when `enable_builtin_processors` is true (default) — a consuming project that wants its own
`\Throwable` handling can set it to `false`.
- `LoggerExtension` (`src/DependencyInjection/`): binds `Logger`'s PSR logger argument to
the configured Monolog channel (reused, not created — the consuming project must declare
it under its own `monolog.channels`), conditionally registers the built-in processors and
`MessagePrefixProcessor`. Every service and argument in this bundle is wired explicitly —
no `autowire()`/`autoconfigure()` on the bundle's own `services.php` — so its dependency
graph never depends on the consuming app's container. See "Symfony Bundles" below.
- `DisallowLoggerInterfaceRule` (`src/PHPStan/`): opt-in PHPStan rule a consuming project
can register in its own `phpstan.neon` to forbid injecting `Psr\Log\LoggerInterface`
directly, forcing all logging through `Logger`.
---
## 🏗️ Architecture
### Overview
```
LogContext enum (any \BackedEnum, defined per-project, per-domain)
│
▼
Logger::error($context, $message, $parameters)
│
├─ formatMessage() "{$context->value} - {$message}"
├─ processParameters() each value → first supports()-matching processor
│
▼
monolog.logger.<channel> (an existing channel — declared by the consuming project under
│ monolog.channels, never created by this bundle)
│
▼
[MessagePrefixProcessor] (only if message_prefix configured — Monolog processor tag)
│
▼
Monolog handlers (project's own monolog.yaml)
```
### Main Components
- `src/Service/Logger.php` — the public API, the only class most consuming code touches.
- `src/Contract/LogParameterProcessorInterface.php` — the extension point for parameter
serialization. Consuming projects add their own implementations freely.
- `src/Processor/ThrowableLogParameterProcessor.php` — default `\Throwable` processor.
- `src/Monolog/MessagePrefixProcessor.php` — optional message-prefixing Monolog processor.
- `src/DependencyInjection/{Configuration,LoggerExtension}.php` — config schema (`channel`,
`message_prefix`, `enable_builtin_processors`) and wiring (channel argument binding,
conditional processor registration).
- `src/PHPStan/DisallowLoggerInterfaceRule.php` — optional enforcement rule.
---
## 🚀 Typical Use Cases
- Any Symfony app that wants one consistent, structured logging entry point instead of
scattering `LoggerInterface` injections and ad hoc `$logger->info('message: ' . $foo)`
string concatenation across the codebase.
- Logging domain events with a typed context (`enum OrderLogContext: string { case
PaymentFailed = 'PaymentFailed'; }`) instead of free-text categories.
- Logging exceptions (or other domain objects, via a custom processor) without manually
extracting `getMessage()`/`getTrace()`/etc. at every call site.
---
## 💡 Design Patterns Used
- **Strategy pattern** for parameter processors (`LogParameterProcessorInterface`):
first-match-wins over a tagged iterable, so behavior is added by writing a new class,
never by editing `Logger` itself.
- **Marker-less extensibility**: log contexts are plain `\BackedEnum` cases — no interface
to implement, no bundle coupling in domain code, just a backed enum per domain.
---
## Project breakdown
- `src/LoggerBundle.php` — bundle entry point.
- `src/Contract/` — the one public extension point (`LogParameterProcessorInterface`).
- `src/Processor/` — bundled processor implementations (currently: throwables only).
- `src/Monolog/` — Monolog-specific processors (currently: message prefixing).
- `src/Service/Logger.php` — the logger service.
- `src/DependencyInjection/` — configuration schema and container wiring.
- `src/PHPStan/` — optional static-analysis enforcement.
- `tests/App/` — minimal Symfony test application (FrameworkBundle + MonologBundle +
LoggerBundle) used to boot a real kernel in Functional/Integration tests.
**IMPORTANT**: This section should evolve with the project. When a new feature is created,
updated or removed, this section should too.
## 🧪 Testing
This bundle is covered by unit, integration, functional, and PHPStan-rule tests, in
`tests/{Unit|Integration|Functional|PHPStan}`. Unit tests use PHPUnit mocks/stubs.
Integration tests exercise `LoggerExtension` directly against a `ContainerBuilder` (no HTTP
kernel). Functional tests boot `tests/App/TestKernel` end-to-end and assert against a
`Monolog\Handler\TestHandler` — including a processor defined outside the bundle, to prove
the extensibility contract actually autowires for a consuming project. PHPStan-rule tests
use `PHPStan\Testing\RuleTestCase` against fixtures in `tests/PHPStan/data/` (excluded from
this project's own CS-Fixer/PHPStan runs — they intentionally contain violations).
---
## Remarks & Guidelines
### General
- NEVER commit or push the git repository.
- When unsure about something, you MUST ask the user for clarification. Same goes if the
user request is unclear.
- When facing a problem that has an easy "hacky" solution, and a more robust but more
difficult to implement one, always choose the robust one:
- Easy hacky fixes become technical debt, and can lead to issues down the road
- Robust solutions means the project will remain serious and well-built.
- ALWAYS write tests for the important components. Better safe than sorry!
- Do NOT write ANY type documentation unless explicitly asked.
- Once a feature is complete, update the @README.md and @AGENTS.md accordingly.
- The @README.md file should consist of a project overview for end-users, not a technical
explanation of the project.
- Keep comments and documentation succinct — state what's non-obvious, don't over-explain.
### Commits
- Format: `type(domain): quick explanation`, where `type` is one of `feat`, `fix`, `docs`,
`tests`. Keep the explanation short — this is a summary line, not a changelog entry.
### Symfony Bundles
- This bundle is meant to be installed via Composer across multiple Symfony projects. When
developing features, keep this in mind: no hardcoded channel names, prefixes, or
project-specific assumptions — those belong in configuration.
- Architecture, naming, design, extensibility and easiness to install and use should be key
priorities to consider when developing this project.
- Never use `autowire()`/`autoconfigure()` defaults for the bundle's own services in
`src/Resources/config/services.php`. Wire every argument explicitly (`Reference`,
`tagged_iterator()`, `Definition::addTag()`) so the bundle's dependency graph is fixed and
doesn't depend on what the consuming app's container happens to contain. This does not
apply to consuming projects' own code — they can and should autowire normally; it's only
about this bundle's own service definitions.
## 📚 References
- **Source code**: `/src`
- **Tests**: `/tests`
- **README**: User documentation
- **Symfony Docs**: https://symfony.com/doc/current/bundles.html
A Symfony bundle providing a single, reusable Logger service that wraps PSR-3 Monolog
channels with two things: structured context tagging (every log call is tied to a
\BackedEnum case, e.g. OrderContext::PaymentFailed) and pluggable parameter
serialization (non-scalar log parameters — exceptions, domain objects — are converted to a
loggable shape by the first matching processor, instead of relying on Monolog's generic
object normalization).
Apps that log through the raw Psr\Log\LoggerInterface tend to end up with unstructured
messages, hardcoded channel and prefix logic scattered across the codebase, and ad hoc
serialization of exceptions or domain objects repeated at every call site. This bundle
centralizes that as a single reusable service, with the project-specific bits (channel name,
message prefix) exposed as configuration instead of hardcoded.
Logger (src/Service/Logger.php): a final service with the full 8-level PSR-3 interface
(emergency → debug). Every method takes a \BackedEnum $context (any backed enum —
no bundle-specific marker interface to implement), a string $message, and an
array $parameters. It formats the message as "{$context->value} - {$message}" and
runs every parameter through the first tagged processor whose supports() returns true.LogParameterProcessorInterface (src/Contract/): the extensibility contract. Any class
implementing it — in this bundle or in a consuming project — is auto-registered and
auto-injected into Logger, no service configuration needed. Tagging is wired
imperatively by LoggerExtension::load() via $container->registerForAutoconfiguration(),
not by a #[AutoconfigureTag] attribute on the interface — that attribute only
self-applies when the consuming app's own resource-scanned service discovery happens to
walk the file declaring it, which never happens for a file living in vendor/. Do not
"simplify" this back to an attribute; it silently breaks processor autowiring for every
consuming project's own custom processors (see tests/Functional/LoggerFunctionalTest.php::testACustomProcessorRegisteredOutsideTheBundleIsAutowired,
which is designed to fail if this regresses).ThrowableLogParameterProcessor (src/Processor/): the only built-in processor, serializing
\Throwable (recursively, including getPrevious()). Registered by LoggerExtension
when enable_builtin_processors is true (default) — a consuming project that wants its own
\Throwable handling can set it to false.LoggerExtension (src/DependencyInjection/): binds Logger's PSR logger argument to
the configured Monolog channel (reused, not created — the consuming project must declare
it under its own monolog.channels), conditionally registers the built-in processors and
MessagePrefixProcessor. Every service and argument in this bundle is wired explicitly —
no autowire()/autoconfigure() on the bundle's own services.php — so its dependency
graph never depends on the consuming app's container. See "Symfony Bundles" below.DisallowLoggerInterfaceRule (src/PHPStan/): opt-in PHPStan rule a consuming project
can register in its own phpstan.neon to forbid injecting Psr\Log\LoggerInterface
directly, forcing all logging through Logger.LogContext enum (any \BackedEnum, defined per-project, per-domain)
│
▼
Logger::error($context, $message, $parameters)
│
├─ formatMessage() "{$context->value} - {$message}"
├─ processParameters() each value → first supports()-matching processor
│
▼
monolog.logger.<channel> (an existing channel — declared by the consuming project under
│ monolog.channels, never created by this bundle)
│
▼
[MessagePrefixProcessor] (only if message_prefix configured — Monolog processor tag)
│
▼
Monolog handlers (project's own monolog.yaml)
src/Service/Logger.php — the public API, the only class most consuming code touches.src/Contract/LogParameterProcessorInterface.php — the extension point for parameter
serialization. Consuming projects add their own implementations freely.src/Processor/ThrowableLogParameterProcessor.php — default \Throwable processor.src/Monolog/MessagePrefixProcessor.php — optional message-prefixing Monolog processor.src/DependencyInjection/{Configuration,LoggerExtension}.php — config schema (channel,
message_prefix, enable_builtin_processors) and wiring (channel argument binding,
conditional processor registration).src/PHPStan/DisallowLoggerInterfaceRule.php — optional enforcement rule.LoggerInterface injections and ad hoc $logger->info('message: ' . $foo)
string concatenation across the codebase.enum OrderLogContext: string { case PaymentFailed = 'PaymentFailed'; }) instead of free-text categories.getMessage()/getTrace()/etc. at every call site.LogParameterProcessorInterface):
first-match-wins over a tagged iterable, so behavior is added by writing a new class,
never by editing Logger itself.\BackedEnum cases — no interface
to implement, no bundle coupling in domain code, just a backed enum per domain.src/LoggerBundle.php — bundle entry point.src/Contract/ — the one public extension point (LogParameterProcessorInterface).src/Processor/ — bundled processor implementations (currently: throwables only).src/Monolog/ — Monolog-specific processors (currently: message prefixing).src/Service/Logger.php — the logger service.src/DependencyInjection/ — configuration schema and container wiring.src/PHPStan/ — optional static-analysis enforcement.tests/App/ — minimal Symfony test application (FrameworkBundle + MonologBundle +
LoggerBundle) used to boot a real kernel in Functional/Integration tests.IMPORTANT: This section should evolve with the project. When a new feature is created, updated or removed, this section should too.
This bundle is covered by unit, integration, functional, and PHPStan-rule tests, in
tests/{Unit|Integration|Functional|PHPStan}. Unit tests use PHPUnit mocks/stubs.
Integration tests exercise LoggerExtension directly against a ContainerBuilder (no HTTP
kernel). Functional tests boot tests/App/TestKernel end-to-end and assert against a
Monolog\Handler\TestHandler — including a processor defined outside the bundle, to prove
the extensibility contract actually autowires for a consuming project. PHPStan-rule tests
use PHPStan\Testing\RuleTestCase against fixtures in tests/PHPStan/data/ (excluded from
this project's own CS-Fixer/PHPStan runs — they intentionally contain violations).
type(domain): quick explanation, where type is one of feat, fix, docs,
tests. Keep the explanation short — this is a summary line, not a changelog entry.autowire()/autoconfigure() defaults for the bundle's own services in
src/Resources/config/services.php. Wire every argument explicitly (Reference,
tagged_iterator(), Definition::addTag()) so the bundle's dependency graph is fixed and
doesn't depend on what the consuming app's container happens to contain. This does not
apply to consuming projects' own code — they can and should autowire normally; it's only
about this bundle's own service definitions./src/testsHow can I help you explore Laravel packages today?