# 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: ```php 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`: ```php $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: ```php // 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: ```php 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: ```php 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 `Subject`s. - 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`: ```php 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: ```php 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 ? ''.$label.'' : $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 `Subject`s 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`