Attributes and audit queries.
Mark entities with attributes, reduce noisy logs with conditions, track read access,
mask sensitive values, and query audit history with AuditReaderInterface.
Auditable Attributes
Use #[Auditable] on a Doctrine entity to enable write auditing. The class-level
options let you disable auditing or ignore specific fields.
<?php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Rcsofttech\AuditTrailBundle\Attribute\Auditable;
#[ORM\Entity]
#[Auditable(enabled: true, ignoredProperties: ['internalCode'])]
class Product
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
public private(set) ?int $id = null;
public function __construct(
#[ORM\Column(length: 180)]
public private(set) string $name,
#[ORM\Column]
public private(set) int $priceInCents,
#[ORM\Column(length: 40, nullable: true)]
public private(set) ?string $internalCode = null,
) {
}
}
Conditional Auditing
Use #[AuditCondition] when an entity should be audited only for certain runtime
conditions. Expressions receive object, action,
changeSet, and user. The action variable is the enum
backing value, such as create, update, or delete; custom
PHP voters receive the real AuditAction enum.
<?php
declare(strict_types=1);
use Doctrine\ORM\Mapping as ORM;
use Rcsofttech\AuditTrailBundle\Attribute\AuditCondition;
use Rcsofttech\AuditTrailBundle\Attribute\Auditable;
#[ORM\Entity]
#[Auditable]
#[AuditCondition("action != 'update' or object.priceInCents >= 10000")]
class Product
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
public private(set) ?int $id = null;
public function __construct(
#[ORM\Column]
public private(set) int $priceInCents,
) {
}
}
Custom Voter
<?php
declare(strict_types=1);
namespace App\Audit;
use App\Entity\Product;
use Rcsofttech\AuditTrailBundle\Contract\AuditVoterInterface;
use Rcsofttech\AuditTrailBundle\Enum\AuditAction;
final class ProductAuditVoter implements AuditVoterInterface
{
public function vote(object $entity, AuditAction $action, array $changeSet): bool
{
if (!$entity instanceof Product || $action !== AuditAction::Update) {
return true;
}
$changedFields = array_keys($changeSet);
return $changedFields !== ['lastViewedAt'];
}
}
Access Auditing
#[AuditAccess] tracks sensitive read operations independently from
#[Auditable]. By default, only GET requests are eligible.
<?php
declare(strict_types=1);
use Doctrine\ORM\Mapping as ORM;
use Rcsofttech\AuditTrailBundle\Attribute\AuditAccess;
#[ORM\Entity]
#[AuditAccess(cooldown: 3600, level: 'info', message: 'User accessed sensitive record')]
class SensitiveDocument
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
public private(set) ?int $id = null;
public function __construct(
#[ORM\Column(length: 255)]
public private(set) string $title,
) {
}
}
| Parameter | Default | Meaning |
|---|---|---|
cooldown |
0 |
Suppress duplicate access logs for the same user and entity for this many seconds. |
level |
info |
Log level stored for the access audit. |
message |
null |
Optional human-readable message stored with the access audit. |
Use a cache pool for cross-request cooldowns. Without one, request-level deduplication still works during the current request.
audit_trail:
cache_pool: 'cache.app'
audited_methods: ['GET']
Explicit Read Intent
Use explicit read intent when a route loads an #[AuditAccess] entity but the bundle's
route-name heuristics are not enough. Prefer route defaults because they are available before
Symfony resolves controller entity arguments.
<?php
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Entity\SensitiveDocument;
use Rcsofttech\AuditTrailBundle\Http\AuditRequestAttributes;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class DocumentController extends AbstractController
{
#[Route(
'/admin/documents/{id}',
name: 'admin_document_detail',
defaults: [AuditRequestAttributes::ACCESS_INTENT => true],
methods: ['GET'],
)]
public function detail(SensitiveDocument $document): Response
{
return $this->render('admin/document/detail.html.twig', [
'document' => $document,
]);
}
#[Route(
'/admin/documents/{id}/edit',
name: 'admin_document_edit',
defaults: [AuditRequestAttributes::ACCESS_INTENT => false],
methods: ['GET'],
)]
public function edit(SensitiveDocument $document): Response
{
return $this->render('admin/document/edit.html.twig', [
'document' => $document,
]);
}
}
Use true for routes that should count as a real record view and false
for routes that load the entity only to render a form, preview, or workflow screen.
#[AuditAccess] does not require #[Auditable]. If
#[AuditCondition] is present on the same entity, it is still respected and
receives action = "access".
Access audits are detected from Doctrine postLoad events during the main request and
processed on Symfony's kernel.terminate. The bundle does not perform an extra ORM
flush() from kernel.terminate; deferred database writes use the same
safe writer path as other deferred audits.
Collection Tracking Notes
The bundle tracks direct Doctrine collection diffs and merges scalar-field plus collection
changes from the same flush into one coherent update audit.
- Same-flush relation creation is resolved after final identifiers exist, so collection payloads store final related IDs instead of placeholder class names.
- Bidirectional associations are safest when deleting a related entity should also audit the owning entity's collection change.
- With unidirectional mappings, database join-table cleanup can still happen correctly, but Doctrine may not expose enough reverse-relation context for an owner-side collection audit.
Sensitive Fields
The bundle can mask values from PHP promoted constructor parameters marked with
#[SensitiveParameter] and fields marked with the bundle's
#[Sensitive] attribute.
<?php
declare(strict_types=1);
use Doctrine\ORM\Mapping as ORM;
use Rcsofttech\AuditTrailBundle\Attribute\Auditable;
use Rcsofttech\AuditTrailBundle\Attribute\Sensitive;
#[ORM\Entity]
#[Auditable]
class CustomerProfile
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
public private(set) ?int $id = null;
public function __construct(
#[ORM\Column(length: 255)]
#[\SensitiveParameter]
private string $passwordHash,
#[ORM\Column(length: 32)]
#[Sensitive(mask: '****')]
private string $ssn,
) {
}
}
Context And Events
Every audit log has a JSON context column. Symfony user impersonation via
_switch_user is recorded automatically when available.
Custom Context
Add application metadata by implementing AuditContextContributorInterface.
Autoconfigured services are tagged automatically.
<?php
declare(strict_types=1);
namespace App\Audit;
use Rcsofttech\AuditTrailBundle\Contract\AuditContextContributorInterface;
use Rcsofttech\AuditTrailBundle\Enum\AuditAction;
final class RequestContextContributor implements AuditContextContributorInterface
{
public function contribute(object $entity, AuditAction $action, array $changeSet): array
{
return [
'app_version' => '2026.05',
'correlation_id' => $_SERVER['HTTP_X_REQUEST_ID'] ?? null,
];
}
}
Audit Events
Use events for integration points around creation, transport failure, and Messenger delivery.
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use Rcsofttech\AuditTrailBundle\Event\AuditDeliveryFailedEvent;
use Rcsofttech\AuditTrailBundle\Event\AuditLogCreatedEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
final class AuditOperationsSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
AuditLogCreatedEvent::class => 'onAuditLogCreated',
AuditDeliveryFailedEvent::class => 'onAuditDeliveryFailed',
];
}
public function onAuditLogCreated(AuditLogCreatedEvent $event): void
{
$event->auditLog->context = [
...$event->auditLog->context,
'node' => gethostname(),
];
}
public function onAuditDeliveryFailed(AuditDeliveryFailedEvent $event): void
{
error_log($event->transportError->getMessage());
}
}
Optional AI Metadata
AuditTrailBundle is AI-ready without depending on Symfony AI or any provider package. If you
want AI-derived labels, summaries, or anomaly metadata, implement
AuditLogReadModelAiProcessorInterface. The bundle stores returned metadata under
context.ai.<namespace> and continues auditing if that optional metadata is
unavailable.
A separate bridge bundle or application service can implement this hook with Symfony AI or any other provider. The core bundle only owns the extension point and the audit-log read model.
<?php
declare(strict_types=1);
namespace App\Audit;
use Rcsofttech\AuditTrailBundle\Contract\AuditLogReadModelAiProcessorInterface;
use Rcsofttech\AuditTrailBundle\Query\AuditLogReadModel;
final class AuditRiskProcessor implements AuditLogReadModelAiProcessorInterface
{
public function getNamespace(): string
{
return 'risk';
}
public function processAuditLog(AuditLogReadModel $audit): array
{
return [
'summary' => sprintf('%s %s', $audit->action->value, $audit->entityClass),
'severity' => 'medium',
'anomaly_score' => 0.35,
'changed_fields' => $audit->changedFields,
];
}
}
- Return structured metadata only; do not make audit delivery depend on an AI provider.
- Use a stable namespace so multiple processors can coexist under
context.ai. - The read model includes the action, entity class and ID, created time, user ID, username, IP address, user agent, old/new values, changed fields, transaction hash, and current audit context.
- AI processors run only in delivery-safe phases such as
post_flush,batch_flush, andmanual_flush. - If AI metadata alone would exceed context limits, context normalization removes only the
aipayload and preserves the rest of the audit context. AuditLogAiProcessorInterfaceremains available for 4.x backward compatibility, but it is deprecated since 4.3 and will be removed in 5.0.
AuditReader API
AuditReaderInterface is the main read API. Query methods are chainable and return
immutable query objects. Object helpers such as getHistoryFor(),
getLatestFor(), and hasHistoryFor() resolve Doctrine proxies and lazy
subclasses back to the mapped entity class before querying.
<?php
declare(strict_types=1);
namespace App\Controller;
use Rcsofttech\AuditTrailBundle\Contract\AuditReaderInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
final class ProductAuditController extends AbstractController
{
public function __construct(
private readonly AuditReaderInterface $auditReader,
) {
}
}
Entity History
$product = $productRepository->find(123);
$history = $this->auditReader->getHistoryFor($product);
foreach ($history as $entry) {
printf(
"%s changed by %s at %s\n",
$entry->action,
$entry->username ?? 'system',
$entry->createdAt->format('Y-m-d H:i:s')
);
}
Custom Query
use App\Entity\Product;
use Rcsofttech\AuditTrailBundle\Enum\AuditAction;
$recentPriceChanges = $this->auditReader
->forEntity(Product::class)
->action(AuditAction::Update)
->changedField('priceInCents')
->since(new \DateTimeImmutable('-30 days'))
->limit(50)
->getResults();
Diff Inspection
$entry = $this->auditReader
->forEntity(Product::class, '123')
->updates()
->getFirstResult();
if ($entry?->hasFieldChanged('priceInCents')) {
$oldPrice = $entry->getOldValue('priceInCents');
$newPrice = $entry->getNewValue('priceInCents');
}
Cursor Pagination
$query = $this->auditReader
->forEntity(Product::class)
->limit(25);
$pageOne = $query->getResults();
$nextCursor = $query->getNextCursor();
if ($nextCursor !== null) {
$pageTwo = $this->auditReader
->forEntity(Product::class)
->after($nextCursor)
->limit(25)
->getResults();
}
limit() must be greater than zero, and cursor values must be valid audit-log
UUIDs. changedField() uses native JSON predicates on MySQL, PostgreSQL, and
SQLite, with a batched in-memory fallback elsewhere.
Collection Helpers
$audits = $this->auditReader
->forEntity(Product::class)
->between(new \DateTimeImmutable('-30 days'), new \DateTimeImmutable())
->getResults();
$priceChanges = $audits->filter(fn ($entry) => $entry->hasFieldChanged('priceInCents'));
$byAction = $audits->groupByAction();
$first = $audits->first();
$last = $audits->last();
$count = $audits->count();
$empty = $audits->isEmpty();
Existence Checks
$hasDeletes = $this->auditReader
->forEntity(Product::class)
->deletes()
->exists();
$hasHistory = $this->auditReader->hasHistoryFor($product);
changedField() supports forward keyset pagination with after().
Reverse pagination with before() is rejected for changed-field queries because the
bundle cannot keep that filter stable across database-native and fallback matching paths.