modera/activity-logger-bundle
A Laravel bundle for recording user and system activity, giving you an audit trail of events across your application. Log actions, review histories, and track what changed and who changed it. Useful for compliance, debugging, and monitoring workflows.
First, the reality check. modera/activity-logger-bundle is a Symfony bundle, not a Laravel package. There is no service provider, no facade, no publishable migrations, and no config/activity-logger.php. It expects Symfony's DI container, Doctrine ORM, and the Symfony event dispatcher. Last release was 2016-06-10 (Symfony 2.8/3.x, PHP 5.6/7.0 era), 4 stars, 0 dependents. Do not plan to composer require it into a Laravel app and "just wire it up" — it will not boot.
Two legitimate ways to start:
spatie/laravel-activitylog + your own type registry — the concepts map almost 1:1.Minimal Symfony setup:
composer require modera/activity-logger-bundle
// app/AppKernel.php
new Modera\ActivityLoggerBundle\ModeraActivityLoggerBundle(),
# app/config/config.yml
modera_activity_logger:
enabled: true
activities:
acme.invoice.sent: { label: "Invoice sent" }
acme.user.invited: { label: "User invited" }
Then update the schema for the bundle's Activity entity (doctrine:schema:update --force in dev, a generated migration elsewhere) and import the bundle's routing if you want its listing controller.
Where to look first, in order:
DependencyInjection/Configuration.php — the authoritative list of config keys for this version. Ignore blog posts.Manager/ActivityManagerInterface — the whole public surface: essentially log() plus query/count methods.Manager/DelegatingActivityManager — the indirection layer; it decides which concrete manager actually handles your call.Entity/Activity (or the Model/ActivityInterface it maps) — the storage shape: type, author, description, target, meta, createdAt.First use case: log a domain event with who/what/where, then render a per-record timeline. Something like "Alice sent invoice #4411" — type = acme.invoice.sent, author = Alice, target = Invoice#4411, meta = ['total' => ..., 'request_id' => ...].
Inject the interface, never the concrete class. Autowire Modera\ActivityLoggerBundle\Manager\ActivityManagerInterface (or alias it explicitly in services.yml). This is what keeps you swappable when the delegating manager resolves to a different backend.
final class SendInvoice
{
public function __construct(private ActivityManagerInterface $activityManager) {}
public function __invoke(Invoice $invoice, User $actor): void
{
// ... send ...
$this->activityManager->log(
'acme.invoice.sent',
sprintf('Invoice %s sent to %s', $invoice->getNumber(), $invoice->getRecipientEmail()),
['total' => $invoice->getTotal(), 'request_id' => $this->requestId()],
$actor,
$invoice
);
}
}
Treat the configured type registry as an enum. Define types once in modera_activity_logger.activities, then reference them through PHP constants. The registry is what gives you human-readable labels, filtering, icons/colors in the UI, and (often) priority. A typo'd string silently produces an unlabeled, unfilterable entry — no exception, no warning.
Use target for entity timelines, meta for context. The target is effectively a polymorphic (class, id) pair, so WHERE target = :invoice gives you "everything that ever happened to this invoice." Keep meta to scalars — request ID, IP, a small diff of changed fields. It's serialized into a single column; stuffing entities or large arrays in there is how you get 2 MB rows and unserialize failures after a refactor.
Pass author = null for system/console actions and log them through the same manager rather than a separate channel. The bundle ships console-oriented handling for exactly this; keeping one write path means one query path and one retention policy.
Query with the manager, not the repository. Filter by type/author/target, always pass limit/offset, always order newest-first. If you go around the manager into the Doctrine repository you lose the delegating layer and any filtering it applies — including, potentially, visibility scoping.
Decorate, don't fork, for fan-out. The delegating manager pattern exists so other bundles (or your app) can register additional managers. Use that hook, or a Doctrine postPersist listener on the activity entity, to mirror events to Slack, an audit SIEM, or a Laravel app via HTTP. Do not put the fan-out in the caller — you'll forget it in half the call sites.
Decide sync vs. post-commit early. Logging inside the same transaction as the business write means a rolled-back transaction can still leave (or lose) an activity, depending on ordering. For anything financially or legally relevant, dispatch to a queue or kernel.terminate and log after commit.
Mapping to Laravel, if you're porting:
| Bundle concept | Laravel equivalent |
|---|---|
type + config registry |
log_name + a PHP enum |
description |
description |
meta |
properties (JSON) |
target |
subject() morph |
author |
causer() |
DelegatingActivityManager |
a custom ActivityLogger binding |
Write the port as a small ActivityLogger service with an enum of types and a ->to($subject) builder; you'll get 90% of the bundle's value in an afternoon and none of the abandonment risk.
It will not install on a modern stack. Composer constraints pin Symfony 2.x/3.x and PHP 5.x/7.0-era Doctrine. On PHP 8.1+ / Symfony 5–6 you'll be fighting composer why-not and, if you force it, untyped-property and deprecated-API errors. Fork and patch, or don't. Budget for "we now own this package."
Zero community. 4 stars, 0 dependents, no releases since 2016, no security advisories and nobody to issue them. There is no upgrade path and no one to ask. If it's load-bearing, put a named owner and a pinned commit hash on it.
Schema comes from annotations, not migrations. There are no Laravel-style migration files to publish. doctrine:schema:update in dev will "work" and then silently diverge from production; generate a real migration and diff it.
enabled: false makes logging a no-op, silently. If activities stop appearing, check the config flag and the container before you check your call site:
bin/console debug:container modera_activity_logger --show-private
Confirm which implementation ActivityManagerInterface resolves to. If a decorator is misregistered you'll get a manager that accepts log() and discards it.
Orphaned targets. No foreign key on the target — delete an invoice and its activities survive pointing at a nonexistent ID, and any timeline renderer that lazy-loads the target will 500. Either null the target in a preRemove listener or render defensively (target ?? 'deleted').
Type typos are invisible. No validation against the registry at log time. Centralize type strings as constants and add a dev-environment assertion that every logged type exists in config.
Check the listing controller's access control. The bundle ships a UI/endpoint for browsing activities; whether it's behind your firewall and scoped to the current user is your configuration problem. An unscoped activity list is a data-leak: it exposes other users' actions, IPs, and metadata. Verify with a second, low-privilege account before shipping.
No retention policy. The table grows forever with serialized meta. Write a purge command on day one (e.g. keep 90 days of acme.*, 7 years of anything flagged audit-critical) — retrofitting one onto 50M rows is a migration project.
Debugging reads: query the table directly first (type, created_at, target_id) to separate "not logged" from "not displayed". The two failure modes look identical in the UI and have completely different causes.
Extension points worth knowing: the model/interface indirection for the activity entity (map your own subclass if you need extra columns), a custom manager registered with the delegating manager (swap Doctrine for something else), and Doctrine lifecycle listeners on the activity entity (fan-out, enrichment, retention). Anything beyond those three means forking — and at that point, seriously reconsider whether a maintained alternative or a 200-line in-house implementation is the better bet.
How can I help you explore Laravel packages today?