locksyk / audit-trail-bundle
Append-only audit trails over Doctrine flushes: the bundle owns changeset normalization, sensitive-field skipping and the guarded write; your application owns the entities, actors and verbs.
Package info
github.com/LocksyK/audit-trail-bundle
Type:symfony-bundle
pkg:composer/locksyk/audit-trail-bundle
Requires
- php: >=8.4
- doctrine/orm: ^3.0
- symfony/config: ^7.0 || ^8.0
- symfony/dependency-injection: ^7.0 || ^8.0
- symfony/http-kernel: ^7.0 || ^8.0
- symfony/uid: ^7.0 || ^8.0
Requires (Dev)
- api-platform/doctrine-orm: ^4.3
- doctrine/dbal: ^4.3
- friendsofphp/php-cs-fixer: ^3.95
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^13.0
- symfony/cache: ^7.0 || ^8.0
- symfony/doctrine-bridge: ^7.0 || ^8.0
- symfony/security-core: ^7.0 || ^8.0
- symfony/serializer: ^7.0 || ^8.0
Suggests
- symfony/doctrine-bridge: Registers the uuid Doctrine type AuditEntryTrait's id column uses
- symfony/security-core: Enables AuditActorResolver (token-based attribution, impersonator unwrapping)
- symfony/serializer: The audit_trail:read serialization group on AuditEntryTrait's properties
README
Append-only audit trails over Doctrine flushes, split along a simple seam: the bundle owns the mechanics - changeset capture during onFlush, value normalization (enums, dates, entity refs), sensitive-field skipping, and folding the entries into the flush in progress - while your application owns the semantics: which entities are audited, who the actor was, what actions mean, and how subjects are worded on screen.
Transactional guarantee
Entries are written inside the same flush - and the same database
transaction - as the changes they describe. The writer runs during
onFlush; whatever it persists is folded into the unit of work
already flushing, so there is exactly one flush and one commit. If an
entry cannot be written, the flush throws and the business change
rolls back with it: the operation fails loudly rather than leaving a
committed change with no record. For an application whose charter is
to be the record, that is the correct failure mode - a trail that
can silently miss a committed change is not a trail.
Two consequences of writing before the INSERTs execute:
- Subject identifiers must be application-assigned (UUIDs and the like) if entries record them: a database-generated id does not exist yet when a creation is described. Alternatively reference the subject by association and let Doctrine resolve the key.
write()must only persist new entities - never flush, and never mutate already-managed ones (their changesets are already computed).
Subjects are data, never sentences
The record stores no constructed display strings. A subject is
described as a reference - {id, type, parts} by default, where
parts are the leaf values identifying it ({"system": "Billing", "name": "Reporting"}) - and the wording happens at read time, in the
consuming UI (see the frontend companion below). Facts freeze;
presentation stays current: entries survive renames, and wording can
improve after the fact.
That shape is the shipped default's, not the bundle's.
AuditSubjectDescriberInterface::describe() returns
array<string, mixed> - a payload fragment - and nothing in the
bundle reads a key back out of it: the recorder embeds the fragment
and carries it, while your writer fills the entry's own subject
columns from the subject entity it is handed. It must be an array
with string keys only because a deletion payload is the
description, and that is what a payload is.
{id, type, parts} is really one half of a matched pair:
SubjectPartsDescriberwrites it and declares that shape on itself, so a writer that destructures a reference can depend on the class rather than on the interface;- the frontend's default reader recognizes exactly it.
Replace one and you replace the other. A describer emitting a
different shape passes a readSubjectRef to the view side to say how
to read it (see the frontend companion below); nothing else changes,
because nothing else looks.
Payload shapes, by the entry's action:
created- plain snapshot values;deleted- the subject's full reference: after the row is gone, the reference is all a reader has;- every other action -
[old, new]pairs per changed field.
Subjects appearing as values inside payloads (an entity-valued field, many-to-many elements) are normalized to the same reference shape.
Two things follow from parts being the stored form, both worth deciding before the first entry is written:
- Parts are exportable by construction. They are handed to the frontend to be worded, so nothing belongs in a part that you would not put in an export. This costs nothing over storing a display string: composition adds punctuation, not confidentiality, so a label built from a part is exactly as sensitive as the part.
- A subject's parts keys are a long-term compatibility surface.
Which keys a given type puts in
partsis what your renderers read back over the whole history of your trail, and old entries keep the keys they were written with. Renderer fallbacks (a missing key rendering as a placeholder) are how that stays forward-compatible; there is no migration path, because entries are append-only.
Audited entities implement AuditSubjectInterface (an id and the
parts); the shipped default describer does the rest, so most
applications configure nothing:
final class Thing implements AuditSubjectInterface { public function getAuditSubjectId(): string { /* uuid */ } public function getAuditSubjectParts(): array { return ['name' => $this->name]; } } // Your entry persistence (actor, associations): final class MyEntryWriter implements AuditEntryWriterInterface { /* ... */ }
# config/packages/audit_trail.yaml audit_trail: writer: App\History\MyEntryWriter # describer: defaults to the shipped SubjectPartsDescriber skip_fields: [secretHash, tokenHash, lastUsedAt]
Workflow-style entities can implement AuditActionProviderInterface to
replace the generic "updated" with a domain action ("approved", with a
note) - such updates are recorded even when every changed field was
skip-listed, so pure transitions still land on the record.
An event's identity is the (subjectType, action) column pair -
there is no composed verb string. The two facts stay two columns, so
"all approvals" is one exact filter and nothing ever parses an
order.approved-style string back apart; anything that wants a single
event key (message routing, say) composes one at that edge.
The entry shape
AuditEntryInterface is the read contract every entry entity shares;
AuditEntryTrait is the shipped implementation of its columns:
UUIDv7 id, action, subjectType, subjectId, subjectParts
(JSONB), payload (JSONB), note, and the attribution columns
(below). Your entity composes the trait and keeps everything
app-typed and class-level: the constructor (assign the trait
properties directly - they are your entity's own; the
stampAuditEntry() helper mints the id), the occurredAt column
(timestamp type and precision are app policy, and Doctrine cannot
override an inherited column's type - the interface still demands the
getter), the actor association, timeline anchors, table name,
indexes, API resource configuration. Other column mappings can be
overridden per entity with #[ORM\AttributeOverrides].
Serialization: the trait's properties carry the bundle-owned
audit_trail:read group; add it to your resource's
normalizationContext alongside your own group.
Entry immutability is yours to enforce where the entries live;
AppendOnlyGuard::createSql('my_entry_table') returns the canonical
PostgreSQL statements (row trigger against UPDATE/DELETE plus a
statement trigger against TRUNCATE) for your migration to execute.
Actors
Attribution is typed, because "everything has an email address" would
paper over a real difference. ActorType names three kinds of actor:
human- a person (identifier: email);service- a machine principal, e.g. a service-account API key (identifier: the account/key name);system- the application's own automation, schedulers and the like (identifier: the job name). Without this kind, automation records a null actor - and null must keep meaning "attribution was not captured", which is a finding, never a design.
An ActorRef carries {type, identifier}; the trait stores them as
actorType/actorIdentifier (both null when nothing was captured),
plus impersonatorIdentifier - the human really driving when the
action happened under impersonation. An impersonator is always a
legitimate human user, by invariant.
Resolution
Writers usually resolve the actor from the security token - which
console commands, message consumers and cron do not have. The bundle
registers AuditActorContext as the hand-off point: anything
actorless supplies the actor (ActorRef::system('nightly-import'),
ActorRef::human($email), or your own actor object), the writer
consults it first and falls back to its usual source.
// In a console command (scoped; restores the previous actor even // when the callback throws): $this->actorContext->withActor(ActorRef::human($email), fn () => $this->import($doc));
When symfony/security-core is installed, AuditActorResolver encodes
the whole chain: explicit context first, else the token - unwrapping
SwitchUserToken so impersonated actions record both who they were
done as and who was really driving - else a null ref, which your
writer should treat as reportable. Principals that would be misnamed
by the human default (machine accounts sharing a firewall with
people) implement AuditActorInterface and name their own ref.
Read visibility
Writing is never filtered, but READS can be narrowed by subject type -
e.g. account-change history visible to admins only. Implement
AuditVisibilityPolicyInterface (return null for unrestricted, a
list of visible subject-type strings otherwise - resolve the current
user yourself, the bundle stays security-agnostic) and configure:
audit_trail: # ... visibility: entity: App\Entity\AuditEntry # your entry entity subject_type_field: subjectType # default subject_id_field: subjectId # default (used by carve-outs) policy: App\History\MyVisibilityPolicy
When API Platform is installed, collection AND item queries on the
entry resource are narrowed automatically - an out-of-policy entry
404s like it never existed. AuditVisibilityPolicyInterface is
aliased to your policy, so custom read endpoints (reports, exports)
can autowire it and apply the same rule. An empty list hides
everything; UnrestrictedAuditVisibilityPolicy is the shipped no-op.
Own-subject carve-outs
A policy may additionally implement AuditVisibilityCarveOutsInterface
to expose individual subjects whose TYPE is otherwise hidden - the
canonical case being a user's own credentials, under a credential type
that is otherwise admin-only. carvedOutSubjects()
returns subject-type => [ids...]; those entries become readable
alongside the visible types (with an empty visible-type list, the
carve-outs alone are readable). Carve-outs are ignored when the policy
is unrestricted, and a type mapped to an empty id list is treated as
no carve-out. The lookup runs per read request - keep it cheap (a
user's own credential ids).
Frontend companion
frontend/ holds @locksyk/audit-trail-view, the presentation half
of the recorder's payload contract (plain TypeScript, no framework
dependency). formatValue, payloadRows and summarizePayload turn
payloads into display rows and one-line summaries;
createSubjectRenderer builds your wording module - the per-type
formatters that turn subject references and subject parts into text,
in the one place wording belongs; readSubjectRef says what a
reference looks like when your describer is not the shipped one. See
frontend/README.md.
License
GPL-2.0-only.