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

Doctrine Audit Bundle Laravel Package

kricha/doctrine-audit-bundle

A Symfony bundle that adds automatic audit trails to Doctrine entities. It captures insert, update, and delete operations, stores configurable change history and metadata, and helps you track who changed what and when without manual logging.

View on GitHub
Deep Wiki
Context7

Getting Started

Install the bundle, register it, and enable it — nothing is audited until the config flag is on.

composer require kricha/doctrine-audit-bundle
// config/bundles.php
Kricha\DoctrineAuditBundle\DoctrineAuditBundle::class => ['all' => true],
# config/packages/doctrine_audit.yaml
doctrine_audit:
    enabled: true
    table_prefix: ''
    table_suffix: '_audit'
    timezone: 'UTC'
    ignored_columns: ['created_at', 'updated_at']
    blameable:
        user_guesser_service: App\Audit\UserGuesser

Then create the audit tables:

php bin/console doctrine:schema:update --dump-sql   # inspect first: audit tables appear alongside yours
php bin/console doctrine:schema:update --force

With migrations instead, the bundle's schema listener contributes the *_audit tables to the generated schema, so doctrine:migrations:diff picks them up. Expect one table per audited entity named <table><suffix> (e.g. product_audit) with: id, type (insert|update|delete), object_id, discr, transaction_hash, diffs, blame_id, blame_user, blame_user_fqcn, created_at.

Where to look first: vendor/kricha/doctrine-audit-bundle/src/ — EventSubscriber/AuditSubscriber.php (the onFlush hook that does all the work), Reader/AuditReader.php (your query API), and Configuration/AuditConfiguration.php (table naming/ignore rules). The README is thin; the source is the documentation.

First use case: wire a blame user, then read history for one record:

// App\Audit\UserGuesser.php
final class UserGuesser implements UserGuesserInterface
{
    public function __construct(private Security $security) {}

    public function getUser(): ?UserInterface
    {
        $user = $this->security->getUser();
        return $user instanceof UserInterface ? $user : null;
    }
}

Your User entity implements Kricha\DoctrineAuditBundle\User\UserInterface (a getId() + getUserIdentifier() shape; confirm against the installed source).

// Controller
public function history(int $id, AuditReader $reader): Response
{
    $page = $reader->getAudits(Product::class, $id, 1, 25);

    return $this->render('product/history.html.twig', ['page' => $page]);
}
{% for audit in page.audits %}
  <tr>
    <td>{{ audit.createdAt|date('Y-m-d H:i') }}</td>
    <td>{{ audit.type }}</td>
    <td>{{ audit.blameUser ?? 'system' }}</td>
    <td>
      {% for field, diff in audit.diffs %}
        <div>{{ field }}: {{ diff.old|default('∅') }} → {{ diff.new|default('∅') }}</div>
      {% endfor %}
    </td>
  </tr>
{% endfor %}

Implementation Patterns

Build your own viewer — there is no UI, controller, or API in the box. The bundle is a write-side recorder plus a read-side helper. Typical delivery is: a /{entity}/{id}/history route, a Twig table, and pagination via the page/page_size arguments of getAudits(). dump() the return value once — it comes back as an array envelope (entity, object_id, page, page_size, total, audits) containing AuditEntry objects, not a Doctrine paginator.

Transaction hash as a timeline unit. Every entity flushed in the same flush() shares a transaction_hash. That's the right key for "what happened in this request/save" rather than per-row diffs:

$entry = $reader->getAudit(Product::class, $id, $hash); // one commit's worth of changes

Show it as a grouped timeline: one row per flush, nested diffs per entity. This is what makes the log readable for actions that touch several records at once.

Query the audit tables directly for reporting. They're plain DBAL tables, not entities, so there's no repository. A DBAL query builder joining product_audit on object_id/discr is the pragmatic route for dashboards ("who edited what this week", "which prices changed > 10%"):

$rows = $connection->fetchAllAssociative(
    'SELECT blame_user, COUNT(*) c FROM product_audit
     WHERE created_at > ? GROUP BY blame_user ORDER BY c DESC',
    [$since]
);

Blame with a null-safe guesser. The guesser runs on every flush including CLI, workers, and fixtures, where there's no token. Return null rather than throwing:

public function getUser(): ?UserInterface
{
    $user = $this->security->getUser();
    return $user instanceof UserInterface ? $user : null;
}

For importers or console commands where you do want attribution, set the token programmatically ($this->security->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles()))) around the import, or expose a "set current actor" service your guesser falls back to before hitting Security.

Ignore noisy columns globally, exceptions per entity. updated_at, last_login, counters, and search-vector blobs generate useless diffs. Put them in ignored_columns; if one entity differs, expose a per-entity ignore method on that class (the method name has shifted between this bundle and its DAMA ancestor — check the installed AuditSubscriber for what it actually calls before relying on it).

Disable in tests and fixtures. doctrine_audit.enabled: false under config/packages/test/ keeps your test schema small and stops fixtures from writing thousands of audit rows; keep a small functional test that re-enables it for one entity so the wiring doesn't silently rot.

Batch your flushes when history matters. Ten flush() calls in a loop produce ten transactions and ten disconnected history entries. One flush at the end gives a single coherent hash — which is what your users will expect when they click "history" after a save.

Gotchas and Tips

Only UnitOfWork flushes are captured. $em->createQuery('UPDATE ...')->execute(), $connection->executeStatement(), DBAL migrations, and any native SQL bypass the subscriber entirely. If your app does bulk updates, the audit log will lie to you — wrap such writes in a manual insert or route them through entities.

Audit writes are inside your transaction. That's good (atomic with the change) and bad (every flush costs an extra INSERT per changed entity). High-write entities — sessions, metrics, queues-as-tables — will double your insert volume. Audit the domain entities and skip the plumbing.

Collections and relations are weakly covered. Changes on the owning side are captured; join-table-only changes (a bare many-to-many added via the collection) often produce nothing or a diff on the wrong entity. If relationship history matters, model the join as an entity or log it yourself.

Inheritance stores a discriminator in discr. When reading audits for a subclass in a single-table hierarchy, pass the concrete class you wrote through — a mismatched discr returns an empty page with no error. Empty results are usually a discr/table-name mismatch, not a missing audit.

Table naming must line up with migrations. table_prefix/table_suffix are applied at read time as well as write time. Change them after you have data and every getAudits() call silently returns nothing. Pick a naming scheme on day one and treat it as schema.

Add indexes yourself. The generated audit tables come with a PK and not much else. For any real volume add (discr, object_id, created_at) and (created_at). Otherwise the history page does a full scan per record and the dashboard query gets slower every day — the schema listener won't do this for you.

diffs is a JSON/text blob — don't query into it. Filter/diff in PHP. If you need field-level reporting, either denormalise on write (custom listener) or accept application-side post-processing.

Debugging missing audits, in order: (1) is enabled: true? (2) does the table actually exist? (3) did the flush change anything (an unchanged entity produces no changeset)? (4) is the column in ignored_columns? (5) was the write DQL/native? The subscriber fails silently by design — it never throws into your business transaction, so there is no error to follow.

Extension points are thin, so decorate. There are no domain events to hook. If you need enrichment (request IP, tenant, reason), decorate doctrine_audit.reader for reads, or write your own onFlush listener ordered after the bundle's that appends to the audit row — and be aware you're coupling to an internal table shape.

Version risk is real. Last release is 2022-02-05 against a 9-star fork of the archived DAMADoctrineAuditBundle. Doctrine DBAL 4 / ORM 3 moved the ground under bundles of this vintage (EventSubscriber signatures, ContainerAwareInterface, getEntityChangeSet behaviours). Pin doctrine/dbal and doctrine/orm conservatively, run the functional audit test in CI, and budget for vendoring or forking if you upgrade Symfony/Doctrine beyond what the bundle was built for. Treat it as a dependency you own, not one you consume.

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.
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
adachsoft/php-code-style