Skip to content

Timestamps and blame

phil/doctrine-behaviours
TypeKindRole
Contract\TimestampableinterfacecreatedAt(), updatedAt(), stampCreated(), stampUpdated()
TimestampableEntitytraitThe columns created_at and updated_at, datetime_immutable
TimestampListenerlistener: prePersist, onFlushStamps with the clock's time, in UTC
Contract\BlameableinterfacecreatedBy(), updatedBy(), blameCreated(), blameUpdated()
BlameableEntitytraitThe columns created_by and updated_by, 255 characters, nullable
BlameListenerlistener: prePersist, onFlushBlames whoever the resolver names
Contract\BlameResolverinterfacecurrent(): ?string, who is acting now
FixedBlameBlameResolverAlways the same one, or nobody
BlameEraserserviceFinds and replaces a user's name in every blamed table

When

FlushTimestampsBlame
an entity persistedcreatedAt, then updatedAt, set to now unless already givencreatedBy, then updatedBy, set to whoever acts unless already given
an entity updatedupdatedAt set to now, unless the update itself sets itupdatedBy set to whoever acts, unless the update sets it
nobody known—nothing written

What an entity was given stays: an import that keeps its original dates and authors calls stampCreated(), blameCreated(), stampUpdated() or blameUpdated() itself, and the listeners leave those values alone.

Blame is a name of the user — what the resolver gives — not a relation to a user entity: the column outlives the account, and no entity has to know the user class.

Privacy

What the resolver gives is copied into every blamed row and kept after the account is gone. When it is an e-mail address — Symfony Security's getUserIdentifier() often is — the blame columns hold personal information. Name users by a stable identifier that is none, such as a UUID, where the resolver allows it.

BlameEraser answers access and erasure requests: find($who) counts the rows that blame someone in each blamed table, and replace($who, $replacement) puts a pseudonym, or null, in their place, in created_by and updated_by of every Blameable entity — in the parent's table for a subclass that inherits them. It works on the tables directly, so soft-deleted rows and rows any SQL filter hides are found too, and no listener stamps the rows it changes. tables() lists every blamed table, found or not, for a report that names each store.

$eraser->find('ada@example.com');                 // ['article' => 12, 'page' => 3]
$eraser->replace('ada@example.com', 'former-member-81');
$eraser->tables();                                // ['article', 'note', 'page']