Skip to main content
Version: 4.x

Extra Data

The extra_data column allows you to store arbitrary supplementary information alongside each audit entry. This is useful for capturing contextual data that isn't part of the entity's fields, such as department, role, request metadata, or any business-specific information.

How It Works

Each audit entry has a nullable JSON extra_data column. By default, it is NULL (zero overhead when not used).

There are two complementary ways to populate it — you can use either or both:

  1. extra_data_provider callable (global): configured once on Configuration, automatically applied to every audit entry. Ideal for request-level context such as the current route name, request ID, or tenant identifier.
  2. LifecycleEvent listener (per-entity): an event listener that intercepts each audit entry just before it is persisted and sets (or enriches) extra_data based on the entity class or its properties.

Data Flow

NOTE

When both a provider and a listener are configured, the listener takes precedence because it fires after the provider. You can use the listener to enrich or override data set by the provider.


Approach 1: extra_data_provider Callable

Register a callable on the Configuration object. It is invoked for every audit entry in every transaction. The callable must return ?array — return null to leave extra_data empty.

$auditor->getConfiguration()->setExtraDataProvider(
static fn (): ?array => ['route' => 'app_order_edit', 'env' => 'prod']
);

The returned array is automatically JSON-encoded and stored in extra_data. If the callable returns null, extra_data is stored as NULL.

When to use this approach

Use caseSuitable?
Capture the current route name for every entry
Attach a request ID / correlation ID
Attach tenant / organisation context
Attach entity-specific data (e.g. $entity->getDepartment())❌ Use a listener instead

Approach 2: LifecycleEvent Listener

Create an event listener that listens to LifecycleEvent. The event provides access to both the payload and the original entity object.

<?php

namespace App\EventListener;

use App\Entity\User;
use DH\Auditor\Event\LifecycleEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener(event: LifecycleEvent::class, priority: 10)]
final class AuditExtraDataListener
{
public function __invoke(LifecycleEvent $event): void
{
$payload = $event->getPayload();

// Filter by entity class
if ($payload['entity'] !== User::class || null === $event->entity) {
return;
}

// Attach extra data as a JSON string
$payload['extra_data'] = json_encode([
'department' => $event->entity->getDepartment(),
'role' => $event->entity->getRole(),
], JSON_THROW_ON_ERROR);

$event->setPayload($payload);
}
}

With Service Injection

Since the listener is a standard Symfony service, you can inject any dependency:

<?php

namespace App\EventListener;

use App\Entity\Order;
use DH\Auditor\Event\LifecycleEvent;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\RequestStack;

#[AsEventListener(event: LifecycleEvent::class, priority: 10)]
final class OrderAuditExtraDataListener
{
public function __construct(
private readonly Security $security,
private readonly RequestStack $requestStack,
) {}

public function __invoke(LifecycleEvent $event): void
{
$payload = $event->getPayload();

if ($payload['entity'] !== Order::class) {
return;
}

$request = $this->requestStack->getCurrentRequest();

$payload['extra_data'] = json_encode([
'admin_user' => $this->security->getUser()?->getUserIdentifier(),
'route' => $request?->attributes->get('_route'),
'reason' => $request?->headers->get('X-Audit-Reason'),
], JSON_THROW_ON_ERROR);

$event->setPayload($payload);
}
}

Merging Provider and Listener Data

When both a provider and a listener are active, you can merge their contributions in the listener:

public function __invoke(LifecycleEvent $event): void
{
$payload = $event->getPayload();

if ($payload['entity'] !== User::class) {
return;
}

// Decode what the provider already set (if anything)
$existing = null !== $payload['extra_data']
? json_decode($payload['extra_data'], true, 512, JSON_THROW_ON_ERROR)
: [];

// Merge entity-specific data
$merged = array_merge($existing, [
'department' => $event->entity->getDepartment(),
]);

$payload['extra_data'] = json_encode($merged, JSON_THROW_ON_ERROR);
$event->setPayload($payload);
}

When to use this approach

Use caseSuitable?
Attach entity-specific data (e.g. $entity->getDepartment())
Filter by entity class
Conditionally skip extra_data for certain entities
Apply the same data to every entity without conditions❌ Use a provider instead

Reading Extra Data

NOTE

Reading and filtering audit entries (including by extra_data content) is provider-specific. For DoctrineProvider, see Extra Data — Filtering.

The Entry model provides access to extra_data via the extraData property or the getExtraData() method:

$reader = new Reader($provider);
$entries = $reader->createQuery(User::class)->execute();

foreach ($entries as $entry) {
$extraData = $entry->extraData; // ?array (decoded JSON)

if (null !== $extraData) {
echo sprintf(
"Department: %s, Role: %s\n",
$extraData['department'] ?? 'N/A',
$extraData['role'] ?? 'N/A',
);
}
}

Both $entry->extraData and $entry->getExtraData() return:

  • null if no extra data was set
  • An associative array (decoded from JSON) otherwise

Schema Update

The extra_data column is added automatically when you run the schema update command:

# Preview the SQL that will be executed
php bin/console audit:schema:update --dump-sql

# Apply the change
php bin/console audit:schema:update --force

TIP

No manual migration is needed. The column uses the same JSON type as diffs (with automatic TEXT fallback on platforms that don't support native JSON).


Important Caveats

Entity State in remove() Operations

WARNING

During a remove operation, the entity object is still in memory but has been detached from the Unit of Work.

  • Direct property access works (e.g., $entity->getName())
  • Lazy-loaded associations may not be accessible (they will throw or return null)

If you need association data during deletions, ensure those associations are eagerly loaded or fetch the data before the flush.

Do Not Write to the Audited EntityManager

CAUTION

The LifecycleEvent is dispatched during a flush. The listener executes synchronously between notify() and persist(), within the same database transaction.

  • SELECTs are safe (reading from another entity manager or connection)
  • INSERT/UPDATE/DELETE on the audited EntityManager will interfere with the ongoing flush and may cause unexpected behavior

If you need to perform write operations based on audit data, defer them (e.g., using a Symfony Messenger message).

JSON Encoding

WARNING

The extra_data value in the payload must be either null or a JSON-encoded string (not an array). Always use json_encode() when setting it in a listener:

// Correct
$payload['extra_data'] = json_encode(['key' => 'value'], JSON_THROW_ON_ERROR);

// Incorrect - will not be stored properly
$payload['extra_data'] = ['key' => 'value'];

The extra_data_provider callable is exempt from this — it must return ?array and the encoding is done automatically.

Performance

AspectImpact
WriteNegligible (+1 column in INSERT)
ReadNegligible (+1 column in SELECT, lazy decoding)
StorageNULL when neither provider nor listener is active (zero overhead)

Next Steps