rasuvaeff / yii3-audit-log
Audit trail for Yii3 applications: who changed what and when, with sensitive value masking
Requires
- php: 8.3 - 8.5
- psr/clock: ^1.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.33
- maglnet/composer-require-checker: ^4.17
- rector/rector: ^2.4
- roave/backward-compatibility-check: ^8.0
- symfony/uid: ^7.0 || ^8.0
- testo/bridge-infection: ^0.1.6
- testo/testo: ^0.10.25
- vimeo/psalm: ^6.16
Suggests
- symfony/uid: Bind Uuid7IdGenerator to get time-ordered audit event ids (UUIDv7 as 32 hex chars)
This package is auto-updated.
Last update: 2026-07-25 11:13:16 UTC
README
Audit trail for Yii3 applications: who changed what and when, with sensitive value masking. Stateless core — bring your own writer (DB adapter lives in a separate package).
Using an AI coding assistant? llms.txt has a compact API reference you can use. Projects using the llm/skills Composer plugin also get this package's agent skill synced into
.agents/skills/automatically on install.
Requirements
- PHP 8.3+
psr/clock^1.0symfony/uid^7.0 || ^8.0 — optional, only forUuid7IdGenerator
Installation
composer require rasuvaeff/yii3-audit-log
Yii3 config-plugin
The package ships config/di.php and config/params.php via config-plugin.
It wires AuditLogger, SensitiveValueMasker and
AuditEventIdGeneratorInterface (to RandomHexIdGenerator), but intentionally
does not bind AuditWriter or Psr\Clock\ClockInterface. Install exactly one writer
adapter or bind AuditWriter in your application config:
use Psr\Clock\ClockInterface; use Rasuvaeff\Yii3AuditLog\AuditWriter; return [ AuditWriter::class => MyAuditWriter::class, ClockInterface::class => MyClock::class, ];
Default params:
return [ 'rasuvaeff/yii3-audit-log' => [ 'sensitiveKeys' => ['password', 'secret', 'token', 'api_key', 'credit_card'], 'skipEmptyChangeSets' => true, ], ];
Usage
Event ids
Event ids are the primary key of the audit table, so their format is a choice, not an implementation detail:
| Generator | id | When |
|---|---|---|
RandomHexIdGenerator (default) |
32 random hex characters | anything; keeps the historical format |
Uuid7IdGenerator |
UUIDv7 rendered as 32 hex characters | large, growing audit tables |
A random primary key scatters InnoDB inserts across pages (an audit table is
append-only and only grows, so the fragmentation compounds), while a
time-ordered one appends. It also sorts chronologically, which gives a cheap
tie-breaker for events within the same second — occurred_at alone cannot
order them.
Uuid7IdGenerator strips the dashes deliberately: 32 characters is the same
width as the default format, so it drops into the existing VARCHAR(32)
column of rasuvaeff/yii3-audit-log-db
with no migration. It needs symfony/uid — a suggest, installed only if you
use it:
composer require symfony/uid
// config/common/di/audit-log.php use Rasuvaeff\Yii3AuditLog\AuditEventIdGeneratorInterface; use Rasuvaeff\Yii3AuditLog\Uuid7IdGenerator; return [ AuditEventIdGeneratorInterface::class => Uuid7IdGenerator::class, ];
Application definitions win over the package's, so this one line is the whole
switch. Your own implementation of AuditEventIdGeneratorInterface (ULID, a
project-wide id scheme, a fixed value in tests) binds the same way.
Switching the generator does not rewrite existing rows: old events keep their random ids, new ones are ordered. Both are 32 hex characters, so nothing downstream needs to change.
Basic logging
use Rasuvaeff\Yii3AuditLog\AuditActor; use Rasuvaeff\Yii3AuditLog\AuditChangeSet; use Rasuvaeff\Yii3AuditLog\AuditLogger; use Rasuvaeff\Yii3AuditLog\AuditSubject; use Rasuvaeff\Yii3AuditLog\InMemoryAuditWriter; $logger = new AuditLogger(writer: $writer, clock: $clock); $logger->logChange( actor: AuditActor::user(id: $userId, name: 'John'), subject: AuditSubject::of(type: 'order', id: (string) $orderId), changes: AuditChangeSet::fromArrays( old: ['status' => 'new', 'total' => 0], new: ['status' => 'paid', 'total' => 99.95], ), );
Implementing a writer
use Rasuvaeff\Yii3AuditLog\AuditEvent; use Rasuvaeff\Yii3AuditLog\AuditWriter; final readonly class DbAuditWriter implements AuditWriter { public function write(AuditEvent $event): void { // INSERT INTO audit_log ... // $event->getId(), $event->getActor(), $event->getAction(), // $event->getSubject(), $event->getChangeSet(), $event->getOccurredAt() } }
Sensitive value masking
use Rasuvaeff\Yii3AuditLog\AuditLogger; use Rasuvaeff\Yii3AuditLog\SensitiveValueMasker; $logger = new AuditLogger( writer: $writer, clock: $clock, masker: new SensitiveValueMasker(), // masks password, secret, token, api_key, credit_card ); // Custom sensitive keys: $masker = new SensitiveValueMasker(sensitiveKeys: ['ssn', 'pin', 'password']);
System actor
$logger->logCreate( actor: AuditActor::system(), subject: AuditSubject::of(type: 'config', id: 'smtp'), changes: AuditChangeSet::fromArrays(old: [], new: ['host' => 'mail.example.com']), );
Request metadata
use Rasuvaeff\Yii3AuditLog\AuditMetadata; $logger->logChange( actor: $actor, subject: $subject, changes: $changes, metadata: new AuditMetadata( requestId: $request->getHeaderLine('X-Request-Id'), ip: $request->getServerParams()['REMOTE_ADDR'] ?? null, userAgent: $request->getHeaderLine('User-Agent'), ), );
API reference
AuditLogger
| Method | Description |
|---|---|
__construct(writer, clock, masker?, skipEmptyChangeSets?, idGenerator?) |
Defaults: skip empty sets = true, id generator = RandomHexIdGenerator |
log(actor, action, subject, changes, metadata?) |
Generic log |
logCreate(actor, subject, changes, metadata?) |
action = 'create' |
logChange(actor, subject, changes, metadata?) |
action = 'update' |
logDelete(actor, subject, changes, metadata?) |
action = 'delete' |
AuditEventIdGeneratorInterface
| Implementation | Produces |
|---|---|
RandomHexIdGenerator (default) |
32 hex characters, 128 random bits |
Uuid7IdGenerator |
UUIDv7 as 32 hex characters (needs symfony/uid) |
AuditActor
| Method | Description |
|---|---|
::user(id, name?) |
User actor |
::system() |
System actor (id = null) |
getType() |
'user', 'system', or custom |
getId() |
?string |
getName() |
?string |
isSystem() |
bool |
AuditSubject
| Method | Description |
|---|---|
::of(type, id) |
Factory |
getType() |
Resource type |
getId() |
Resource ID |
AuditChangeSet
| Method | Description |
|---|---|
::fromArrays(old, new) |
Computes diff; only changed fields included |
::empty() |
Empty change set |
getChanges() |
list<AuditChange> |
isEmpty() |
bool |
count() |
Number of changes |
AuditChange
| Method | Description |
|---|---|
getField() |
Field name |
getOldValue() |
mixed |
getNewValue() |
mixed |
SensitiveValueMasker
| Method | Description |
|---|---|
__construct(sensitiveKeys?) |
Default: password, secret, token, api_key, credit_card |
mask(array) |
Returns array with sensitive values replaced by *** |
maskChangeSet(AuditChangeSet) |
Returns new AuditChangeSet with masked values |
Security
- Masker is applied inside
AuditLoggerbefore the event reaches the writer — secrets never reach storage. - Masking is case-insensitive:
Password,PASSWORD,passwordall masked. - DB writer implementations must use parameterized queries.
Examples
See examples/ for complete usage examples.
Development
make install
make build
make cs-fix
make test
make test-coverage
make mutation
make release-check
make test-coverage and make mutation bootstrap pcov inside the
composer:2 container because the base image has no coverage driver.
License
BSD-3-Clause. See LICENSE.md.