Usage

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.

4.x PHP attributes Fluent API

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, and manual_flush.
  • If AI metadata alone would exceed context limits, context normalization removes only the ai payload and preserves the rest of the audit context.
  • AuditLogAiProcessorInterface remains 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.