Files
chill-bundles/docs/source/development/audit.md
Julien Fastré 8d145e3b58 Add audit documentation to developer guide
- Added `audit.md` to explain how to trigger audits, best practices, and internal architecture.
- Updated `mkdocs.yml` to include the new "Audit" section in the developer guide.
2026-02-27 15:27:45 +01:00

8.6 KiB

Audit — developer guide

This page explains how to trigger an audit from your code, recommended practices, how subjects are converted and displayed, and how the whole pipeline persists an AuditTrail.

Triggering an audit

Audits are triggered through the Chill\MainBundle\Audit\TriggerAuditInterface service. It exposes a single method and is also invokable:

use Chill\MainBundle\Audit\TriggerAuditInterface;
use Chill\MainBundle\Entity\AuditTrail; // for action constants
use Symfony\Component\Translation\TranslatableMessage;

final class MyController
{
    public function __construct(private readonly TriggerAuditInterface $triggerAudit) {}

    public function someAction(MyEntity $entity): Response
    {
        // ... persist/flush $entity so that its id is available

        $this->triggerAudit->triggerAudit(
            AuditTrail::AUDIT_UPDATE,        // $action
            $entity,                         // $mainSubject (object)
            subjects: [$entity->getOwner()], // optional related subjects (array of objects)
            description: new TranslatableMessage('audit.entity.updated'), // optional description
            metadata: ['field' => 'value']   // optional machine-readable extras
        );

        // equivalently (because the service is invokable):
        ($this->triggerAudit)(AuditTrail::AUDIT_UPDATE, $entity);

        // ...
    }
}
  • $action: an action label such as AuditTrail::AUDIT_CREATE, AUDIT_UPDATE, AUDIT_DELETE, AUDIT_VIEW, AUDIT_LIST, ...
  • $mainSubject: the primary domain object the action is about.
  • $subjects: additional related domain objects to attach to the audit (optional). The always-related subject should be added through the converter.
  • $description: a short human-readable text, already translated. You may pass a plain string or any TranslatableInterface (e.g. TranslatableMessage).
  • $metadata: free-form associative array to add machine-readable context (ids, filters, counters, etc.).

!!! warning

The id of every subject must be set when you trigger the audit. In practice this means:
- trigger audits after persist operations (once Doctrine assigned an id, i.e. after `flush()` when needed), and
- trigger audits before delete operations (while the id is still available).

See concrete usages in controllers such as:

  • Chill\PersonBundle\Controller\AccompanyingCourseApiController (multiple calls to $this->triggerAudit->triggerAudit(...)).

Example extracted and simplified from AccompanyingCourseApiController:

$this->managerRegistry->getManager()->flush(); // ensure ids are set

$this->triggerAudit->triggerAudit(
    AuditTrail::AUDIT_UPDATE,
    $accompanyingPeriod,
    description: new TranslatableMessage('audit.accompanying_period.set_requestor'),
    metadata: ['t' => $accompanyingPeriod->getRequestorKind(), 'requestor' => $accompanyingPeriod->getRequestor()->getId()],
);

Best practices

  • Audit every create, update, delete, view and list action on domain entities.
  • Use only the $action when it is obvious and self-explanatory.
  • Use the $description when the action alone is not enough. For example for a list, the main subject is the person (or the accompanying period) the list is attached to, and the description should summarize the list content (filters, ranges, counts, …).
  • Add $metadata to provide extra, machine-friendly details (ids, filters, pagination, counts, etc.).
  • Reuse the existing patterns found in current controllers and services.

Examples:

// CREATE a person
($this->triggerAudit)(AuditTrail::AUDIT_CREATE, $person);

// VIEW a person details
($this->triggerAudit)(AuditTrail::AUDIT_VIEW, $person);

// LIST resources attached to an accompanying period with filters in metadata
($this->triggerAudit)(
    AuditTrail::AUDIT_LIST,
    $accompanyingPeriod,
    description: 'Listing resources for the current period',
    metadata: ['scope' => 'resources', 'filters' => ['category' => 'housing', 'page' => 1]]
);

Use new entities in Audits

Creating a new SubjectConverter

Subjects are normalized into a serializable Subject model using Chill\MainBundle\Audit\SubjectConverterInterface. Implement a converter when you introduce a new domain type you want to appear in audits.

Interface:

interface SubjectConverterInterface
{
    /**
     * @param object $subject
     */
    public function convert(mixed $subject, bool $includeAssociated = false): SubjectBag;

    /** @phpstan-assert-if-true T $subject */
    public function supportsConvert(mixed $subject): bool;

    public static function getDefaultPriority(): int;
}

Typical implementation skeleton:

use Chill\MainBundle\Audit\{Subject, SubjectBag, SubjectConverterInterface, SubjectConverterManagerAwareInterface, SubjectConverterManagerAwareTrait};

final class MyEntitySubjectConverter implements SubjectConverterInterface, SubjectConverterManagerAwareInterface
{
    use SubjectConverterManagerAwareTrait;

    public function supportsConvert(mixed $subject): bool
    {
        return $subject instanceof MyEntity;
    }

    public function convert(mixed $subject, bool $includeAssociated = false): SubjectBag
    {
        \assert($subject instanceof MyEntity);

        $main = new SubjectBag(
            new Subject(
                type: 'my_entity',
                identifiers: ['id' => $subject->getId()],
            )
        );

        $associated = [];
        if ($includeAssociated && null !== $subject->getOwner()) {
            // we delegate the conversion of the owner to the manager
            $main->append($this->subjectConverterManager->getSubjectsForEntity($subject->getOwner(), false));
        }

        return new SubjectBag($main, $associated);
    }

    public static function getDefaultPriority(): int
    {
        return 0; // higher value = earlier pick when multiple converters support the same type
    }
}
  • SubjectBag groups the primary Subject and optional associated Subjects.
  • Converters are discovered and composed by SubjectConverterManagerInterface.
  • Implement SubjectConverterManagerAwareInterface (and use SubjectConverterManagerAwareTrait) if your converter needs to delegate the conversion of associated entities to the manager (useful if there is already a converter for that entity).

Creating a new SubjectDisplayer

To control how a Subject is rendered in the UI (HTML or plain string), implement Chill\MainBundle\Audit\SubjectDisplayerInterface:

interface SubjectDisplayerInterface
{
    public function supportsDisplay(Subject $subject, array $options = []): bool;

    /** @param 'html'|'string' $format */
    public function display(Subject $subject, string $format = 'html', array $options = []): string;
}

Example: see Chill\PersonBundle\Audit\Displayer\HouseholdCompositionDisplayer. A simplified version:

use Chill\MainBundle\Audit\{Subject, SubjectDisplayerInterface};

final class MyEntityDisplayer implements SubjectDisplayerInterface
{
    public function supportsDisplay(Subject $subject, array $options = []): bool
    {
        return 'my_entity' === $subject->type;
    }

    public function display(Subject $subject, string $format = 'html', array $options = []): string
    {
        $label = sprintf('#%d %s', $subject->identifiers['id'], $subject->labels['name'] ?? '');
        return 'html' === $format ? '<span>'.$label.'</span>' : $label; // <-- here, be aware that there is no xss risk. If any, use twig.
    }
}

!!! warning

Use twig to render html format if there is a risk for xss injection.

How it works internally

  1. Storage: every audit is persisted as an AuditTrail entity.
  2. Subject conversion: the main subject and all additional subjects are converted to Subject models using SubjectConverterInterface implementations, orchestrated by SubjectConverterManagerInterface.
  3. Display: UI rendering uses SubjectDisplayerInterface implementations to render Subjects as HTML or strings.
  4. Persistence: TriggerAuditInterface builds an AuditEvent, converts it to an AuditTrail via AuditEvent2TrailInterface, and persists it through the audit persistence layer (AuditTrailPersisterInterface, i.e. the persistence interface behind the service). There is no need to call EntityManager::flush() for the audit itself, so it is fast; however you must trigger the audit when the subject ids are available (see warning above).

See also:

  • Chill\MainBundle\Audit\TriggerAuditService
  • Chill\MainBundle\Audit\AuditEvent2Trail
  • Chill\MainBundle\Audit\AuditTrailPersister
  • Chill\MainBundle\Audit\SubjectConverterManagerInterface
  • Chill\PersonBundle\Audit\Displayer\HouseholdCompositionDisplayer