- 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.
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 asAuditTrail::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 anyTranslatableInterface(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
$actionwhen it is obvious and self-explanatory. - Use the
$descriptionwhen 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
$metadatato 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
}
}
SubjectBaggroups the primarySubjectand optional associatedSubjects.- Converters are discovered and composed by
SubjectConverterManagerInterface. - Implement
SubjectConverterManagerAwareInterface(and useSubjectConverterManagerAwareTrait) 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
- Storage: every audit is persisted as an
AuditTrailentity. - Subject conversion: the main subject and all additional subjects are converted to
Subjectmodels usingSubjectConverterInterfaceimplementations, orchestrated bySubjectConverterManagerInterface. - Display: UI rendering uses
SubjectDisplayerInterfaceimplementations to renderSubjects as HTML or strings. - Persistence:
TriggerAuditInterfacebuilds anAuditEvent, converts it to anAuditTrailviaAuditEvent2TrailInterface, and persists it through the audit persistence layer (AuditTrailPersisterInterface, i.e. the persistence interface behind the service). There is no need to callEntityManager::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\TriggerAuditServiceChill\MainBundle\Audit\AuditEvent2TrailChill\MainBundle\Audit\AuditTrailPersisterChill\MainBundle\Audit\SubjectConverterManagerInterfaceChill\PersonBundle\Audit\Displayer\HouseholdCompositionDisplayer