Your domain should never import a logger
A call to $this->logger->info() inside an entity looks harmless. It is one line, it does not change any state, and everybody agrees that logging is useful. Yet from that line on, your domain imports a technical interface, your unit tests need a mock to instantiate the entity, and the layer that is supposed to depend on nothing already depends on something.
The problem
The issue is not the log itself. The issue is where it gets triggered. Logging, sending an email, pushing a notification: these are side effects, and a side effect has no place inside a business rule.
Once a logger sneaks into the domain, the damage compounds quietly. Every entity constructor or behavior method that logs now requires a logger instance, so your object mothers and test fixtures carry one around. The domain layer's dependency graph is no longer empty, so you can no longer claim "the domain depends on nothing" in a code review without an asterisk. And the next developer, seeing a logger already there, adds a mailer for the overdue-loan reminder. Each step looks small; the sum is a domain layer wired to infrastructure.
The domain produces facts, nothing else
In Phexium, the domain's only way to communicate that something happened is a domain event. The framework provides the building blocks: an event bus, and plugins that wrap each side effect behind a port and adapter pair. For logging, a LoggerInterface port with FileLogger or NullLogger adapters. For mail, a MailerInterface port with PhpMailer or InMemoryMailer adapters.
The demo application, a library management system, shows the assembly on a concrete use case. Its domain event states a fact and carries the aggregate. Look at its imports: domain types, DateTimeImmutable, and nothing else.
namespace AppDemo\Loan\Domain\Event;
use AppDemo\Loan\Domain\Loan;
use DateTimeImmutable;
use Phexium\Domain\Event\DomainEventAbstract;
use Phexium\Domain\Event\DomainEventInterface;
use Phexium\Domain\Id\IdInterface;
final readonly class LoanCreatedEvent extends DomainEventAbstract implements DomainEventInterface
{
public function __construct(
IdInterface $eventId,
DateTimeImmutable $occurredOn,
private Loan $loan,
) {
parent::__construct($eventId, $occurredOn);
}
public function getLoan(): Loan
{
return $this->loan;
}
public function getAggregateId(): IdInterface
{
return $this->loan->getId();
}
}
The application handler persists the aggregate, then publishes the event on the bus. The fact is now available to anyone interested, but the handler itself does not know who that is.
Listeners trigger the side effects
The side effects live in event listeners, in the application layer. A listener receives the event and does its work through ports, never through concrete implementations:
namespace AppDemo\Library\Application\EventListener;
use AppDemo\Library\Application\Command\UpdateBookStatusCommand;
use AppDemo\Library\Domain\BookStatus;
use AppDemo\Loan\Domain\Event\LoanCreatedEvent;
use Phexium\Plugin\CommandBus\Port\CommandBusInterface;
use Phexium\Plugin\Dispatcher\Port\ListenerInterface;
use Phexium\Plugin\Logger\Port\LoggerInterface;
final readonly class LoanCreatedEventHandler implements ListenerInterface
{
public function __construct(
private CommandBusInterface $commandBus,
private LoggerInterface $logger
) {}
public function __invoke(LoanCreatedEvent $event): void
{
$loan = $event->getLoan();
$command = new UpdateBookStatusCommand($loan->getBookId(), BookStatus::Borrowed);
$this->commandBus->dispatch($command);
$this->logger->info(
$event->getEventName().' handler: Book status updated to BORROWED',
[
'OccuredOn' => $event->getOccurredOn(),
'AggregateId' => $event->getAggregateId()->getValue(),
]
);
}
}
The wiring is a plain configuration array, one event class mapped to its listeners:
The LoggerInterface injected here is the plugin port, not a concrete class. It extends PSR-3, so any PSR-3 adapter fits behind it. In production the container binds FileLogger; in tests, NullLogger makes the whole logging concern disappear without touching a single listener.
Deptrac enforces the boundary on every analysis: the Domain layer depends on nothing, and Application never reaches into Infrastructure. If someone imports a logger into an entity, the build fails before the review even starts.
Trade-offs
The cost is one more indirection. To follow what happens after a loan is created, you cannot read a single method top to bottom; you have to check which listeners subscribe to LoanCreatedEvent. The configuration array keeps that lookup cheap, but it is still a lookup.
There is also a granularity question. Not every log line deserves an event. Diagnostic logging inside an application handler ("processing command X") is fine through the port, directly in the handler. The rule is narrower than "all logs go through listeners": side effects that react to a business fact belong in listeners; the domain itself stays free of both.
What this enables
Unit tests for entities and value objects need zero mocks, because there is nothing to mock. The domain test suite runs without a container, without configuration, without I/O.
Adding a side effect costs one class and one config line. When the overdue-loan email or the Slack notification arrives, you write a listener against MailerInterface, register it under the event, and the domain, the command handler, and the existing listeners stay untouched.
And the boundary is not a convention you hope people respect. It is a Deptrac rule that fails the build. That is the real takeaway: "the domain depends on nothing" only stays true when a tool checks it on every commit.