Recording changes
StaticPHP\Utils\Models\Audit\Audit has nine public methods and no instance:
<?php
namespace StaticPHP\Utils\Models\Audit;
class Audit{ public static function requestId(): string; public static function reset(): void;
public static function store(): Store; public static function setStore(?Store $store): void;
public static function record(AuditEvent $event): void;
public static function insert( string $table, array $data, string $module = '', ?string $entityId = null, ?string $returning = null, array $tags = [], ?array $context = null, ?string $connection = null ): mixed;
public static function update( string $table, array $data, array $where, string $module = '', array $tags = [], ?array $context = null, ?string $connection = null ): PDOStatement;
public static function delete( string $table, mixed $where, string $module = '', array $tags = [], ?array $context = null, ?string $connection = null ): PDOStatement;
public static function diff(?array $before, ?array $after, array $exclude = []): array;}The three wrappers each perform the write themselves, through
Db::insert(),
Db::update() and
Db::delete(), and return exactly what those return -
so a call site can be switched over without changing what it does with the result. The
argument lists are not the same, though: $module sits where Db’s $name does, and the
connection has moved to the end. Only $module is worth passing positionally; everything
after it is a named argument in practice.
$connection only ever moves the change: it reaches Db::insert() / Db::update() /
Db::delete() and self::rows(), nothing else. The audit row goes through
Audit::store(), which is built once from
$config['audit']['connection'] and memoised, so it never sees the per-call argument.
Leaving $connection at null puts the change on that same configured connection, which is
why the change and its audit row usually land together - but passing one explicitly splits
them: the change moves, the trail does not follow it. Two sqlite connections, default
configured as $config['audit']['connection'] and reporting passed to insert():
<?php
Audit::insert('people', ['name' => 'Anna'], connection: 'reporting');people on reporting: 1audit_log on reporting: 0people on default: 0audit_log on default: 1The audit row living in the caller’s transaction is only true when the two
coincide. Wrapping that call in a transaction on reporting and rolling it back removes the
people row but leaves the audit row on default committed:
<?php
Db::beginTransaction('reporting');Audit::insert('people', ['name' => 'Anna'], connection: 'reporting');Db::rollBack('reporting');people on reporting: 0audit_log on reporting: 0audit_log on default: 1insert()
Section titled “insert()”<?php
Audit::insert('people', ['name' => 'Anna', 'city' => 'Dobele'], module: 'catalog');The whole of $data goes into new_values; old_values stays null, because there was
nothing there. Excluded columns are masked rather than dropped, so the log still shows that a
password was set. Every column of the resulting row:
id 1created_at 2026-08-02 01:03:10request_id 61b2fb4290c4a53100ee5f6c8db97939module catalogevent createdentity_type peopleentity_id 1actor_type useractor_id 42actor_name Anna Berzinaold_values NULLnew_values {"name":"Anna","city":"Dobele"}url /people/editip_address 10.0.0.9user_agent Mozilla/5.0tags NULLcontext NULLThe return value is whatever Db::insert() returned, which depends on $returning: the
PDOStatement without it, the fetched row with it.
What fills entity_id
Section titled “What fills entity_id”insertedId() tries three sources in order, and the order matters because the last one
cannot always answer:
$data[$config['audit']['id_key']], when the application supplied the key itself.- The same key on the
$returningresult, whether that came back as an array or an object. Db::lastInsertId('', false, $connection), wrapped so that a driver which refuses returns the empty string rather than failing the insert over a missing audit detail.
Passing entityId: skips all three. The same three Audit::insert() calls on each driver:
===== sqlite ===== {"name":"Anna"} entity_id="1" {"name":"Girts"} entity_id="2" {"name":"Ilze"} entity_id="uuid-8f2c"===== pgsql ===== {"name": "Anna"} entity_id="" {"name": "Girts"} entity_id="2" {"name": "Ilze"} entity_id="uuid-8f2c"===== mysql ===== {"name":"Anna"} entity_id="1" {"name":"Girts"} entity_id="2" {"name":"Ilze"} entity_id="uuid-8f2c"update()
Section titled “update()”<?php
Audit::update('people', ['name' => 'Anna Berzina', 'city' => 'Dobele'], ['id' => 1], module: 'catalog');The rows are read before the update runs, so what is recorded is the change that
happened rather than the change that was requested. city was already Dobele and does not
appear:
module catalogevent updatedentity_type peopleentity_id 1old_values {"name":"Anna"}new_values {"name":"Anna Berzina"}$where takes the same shapes as
Db::update() - it is typed array, so the raw
string escape hatch is not reachable here.
One event per row actually changed. A condition matching three rows of which two change
writes two rows, each with its own entity_id:
rows updated: 2
event updatedentity_id 1old_values {"city":"Dobele"}new_values {"city":"Riga"}
event updatedentity_id 2old_values {"city":"Dobele"}new_values {"city":"Riga"}An update that changes nothing records nothing. Diff::between() returns a null new
value and the loop skips it. The statement still ran, and sqlite still reports the row as
matched:
rows updated: 1(no rows)That is the common case, not an error: writing the values a row already holds is legitimate and happens constantly on a form that submits every field.
delete()
Section titled “delete()”<?php
Audit::delete('people', ['id' => 1], module: 'catalog');Unlike an update there is no second chance to find out what was there, so the whole row goes
into old_values - minus anything the exclude list covers - and new_values is null:
event deletedentity_type peopleentity_id 1old_values {"id":1,"name":"Anna","city":"Dobele","active":1,"password":"***"}new_values NULL$where is mixed here, matching Db::delete(), so the raw string form is accepted and is
concatenated unescaped. Db::select() resolves it identically, so the rows audited are the
rows deleted - here Audit::delete('people', "city = 'Dobele'") against a table holding one
matching row and one that does not:
event deletedentity_id 1old_values {"id":1,"name":"Anna","city":"Dobele","active":1,"password":"***"}people left: 1Nothing is escaped in that form, so it must never be built from request data.
Conditions that match nothing, or everything
Section titled “Conditions that match nothing, or everything”A condition matching nothing is not an error - but it is also exactly what a mistyped
condition looks like, and silence is what lets that ship. Audit::update() and
Audit::delete() say so, through error_log(), and only when $config['debug'] is on,
because on a busy application the benign reading of this happens constantly:
Audit trail: update on "people" matched no rowsAudit trail: delete on "people" matched no rowsWith debug off, nothing is logged at all. The check is Config::getBool('debug') rather than
resolveDebug(), which may call into the
application’s own gate and is far too heavy to run on every write.
The opposite mistake is guarded differently. $config['audit']['max_rows'] - 1000 by default
- is checked before the update runs, so a condition that matches the whole table does not become a write plus half a million audit rows:
StaticPHP\Utils\Models\Audit\AuditError: Refusing to audit 3 rows of "people" in one call; config['audit']['max_rows'] is 2. Narrow the condition, or raise the limit.people after the refusal: [{"id":1,"city":"Dobele"},{"id":2,"city":"Dobele"},{"id":3,"city":"Dobele"}]trail rows: 0The refusal goes through the same failure path as a broken audit write, so strict decides
whether it throws or is logged. Setting max_rows below 1 disables the check - three rows
audited against a limit of 0:
trail rows with max_rows = 0: 3The value has to be a real int. Anything else falls back to 1000, so a limit written as a
string is silently not the limit:
trail rows with max_rows = '2': 3record() and AuditEvent
Section titled “record() and AuditEvent”record() is the layer underneath the three wrappers, and the way to log something they do
not cover - an export, a login, an approval, a state machine transition:
<?php
use StaticPHP\Utils\Models\Audit\Audit;use StaticPHP\Utils\Models\Audit\AuditEvent;
Audit::record(new AuditEvent( event: 'exported', entityType: 'people', entityId: '42', module: 'catalog', actorType: 'cron', actorId: 'nightly-sync', actorName: 'Nightly sync', tags: ['gdpr'], context: ['batch' => 7]));module catalogevent exportedentity_type peopleentity_id 42actor_type cronactor_id nightly-syncactor_name Nightly syncold_values NULLnew_values NULLurl /people/editip_address 10.0.0.9tags ["gdpr"]context {"batch":7}Note what happened to the actor and the context. The explicit actor was kept; the url and ip
address, which this call did not name, were filled in from the resolvers. Nothing performs a
diff, so old_values and new_values are both null unless the caller supplies them.
AuditEvent is a readonly class with sixteen promoted properties and one method:
<?php
readonly class AuditEvent{ public const CREATED = 'created'; public const UPDATED = 'updated'; public const DELETED = 'deleted';
public function __construct( public string $event, public string $entityType, public string $entityId = '', public string $module = '', public ?array $oldValues = null, public ?array $newValues = null, public string $actorType = '', public string $actorId = '', public string $actorName = '', public string $requestId = '', public string $url = '', public string $ipAddress = '', public string $userAgent = '', public array $tags = [], public ?array $context = null, public ?string $createdAt = null, );
public function withResolved(array $actor, array $context, string $requestId): self;}Only $event and $entityType are required. Readonly is the point: an audit entry that can
be edited after the fact is not an audit entry.
event is free text. The three constants are what the wrappers write, and
record() takes anything - a smallint column or a postgres enum would turn adding an event
type into a migration, which is the wrong tax on a log. entityType is a table name, not a
class name, and entityId is text so that bigint and uuid keys both fit.
withResolved()
Section titled “withResolved()”record() calls it, with the actor, the request context and the request id. It returns a
copy in which only the empty fields have been replaced, so a caller that named the actor
itself keeps what it passed:
<?php
$event = new AuditEvent(event: 'created', entityType: 'people', actorName: 'Nightly sync');
$resolved = $event->withResolved( ['type' => 'user', 'id' => '42', 'name' => 'Anna Berzina'], ['url' => '/people/edit', 'ip_address' => '10.0.0.9', 'user_agent' => 'Mozilla/5.0'], 'a1b2c3');actorType: "user"actorId: "42"actorName: "Nightly sync"requestId: "a1b2c3"url: "/people/edit"the original is untouched: ""A field left empty by both the caller and the resolvers stays empty. With no actor resolver
configured, every row records an empty actor rather than failing:
actor_typeactor_idactor_namecreatedAt left at null lets the store stamp the row; supplying one keeps it, which is how
a backfill records when the change happened rather than when it was imported:
created_at 2019-01-01 00:00:00event importedrequestId()
Section titled “requestId()”<?php
Audit::requestId(); // 32 hex charactersGenerated once per process from random_bytes(16), so a request that touches five tables
leaves five rows that can be read back as one action. Under the cli it covers the whole
command run, which is the useful reading for a nightly import:
Audit::requestId() = ba23c9d35c4a82656d2c8601da13f14f
request_id ba23c9d35c4a82656d2c8601da13f14fmodule catalogevent updatedentity_id 1
request_id ba23c9d35c4a82656d2c8601da13f14fmodule importevent createdentity_id 2There is no index on request_id in the shipped schema; one is suggested in the .sql
comments and left for a deployment that queries by it. See
the indexes.
reset()
Section titled “reset()”<?php
Audit::reset();Forgets the memoised settings, the store and the request id - all three, not one. A test that
changes $config['audit'] needs it, and so does anything that serves more than one logical
request in a process:
<?php
$first = Audit::requestId();Audit::reset();$second = Audit::requestId();same request id after reset(): noTags, context and module
Section titled “Tags, context and module”$module is a plain string naming which part of the application made the change. It is
stored in a column rather than deciding a table name; the reasoning is on
the overview. It also reaches a
table resolver, which is how a deployment that does split the trail splits it.
$tags is a list<string>, stored as a json array, or null when the list is empty.
$context is whatever else the application wants to keep, stored as json. Neither is
indexed and neither is read by anything in the framework - they are there so an application
does not have to fork the schema to record one extra field.
When the trail cannot be written
Section titled “When the trail cannot be written”$config['audit']['strict'] decides. On, the default, the failure is rethrown as
AuditError:
StaticPHP\Utils\Models\Audit\AuditError: Audit trail: SQLSTATE[HY000]: General error: 1 no such table: no_such_tablepeople rows: 1Read the second line: the people row is still there. Strict does not undo the change, it
surfaces the failure - and outside a transaction the change has already committed. On
postgres rethrowing is the honest option anyway, because the failed insert has already
aborted the surrounding transaction, so swallowing it would not rescue the change, it would
only hide why the commit fails later.
Off, the same failure goes to error_log() and the call continues:
people rows: 1Audit trail: SQLSTATE[HY000]: General error: 1 no such table: no_such_tableThat is availability over completeness, which is rarely the trade an audit trail wants to make. It is a choice, not a silence: the line is always logged.
AuditError extends \RuntimeException and carries the original exception as its previous,
except when the thing that failed was already an AuditError - a refused table name or a
max_rows refusal - in which case it is rethrown unchanged and has no previous at all:
previous: PDOExceptionprevious on a max_rows refusal: NULLTransactions
Section titled “Transactions”Store::write() inserts on the caller’s connection, so it is inside the caller’s transaction
without doing anything to arrange it. A rolled back change takes its audit row with it:
<?php
Db::beginTransaction();Audit::insert('people', ['name' => 'Anna']);Db::rollBack();people rows: 0trail rows: 0That holds here because $connection was left at null, so the change and the trail were on
the same connection to begin with. The trail itself has no connection of its own to move - it
is always Audit::store()’s, fixed at $config['audit']['connection']. Pass $connection
explicitly, as above, and it does not move the trail with the change; it splits the two, and
this guarantee holds no further than the connection the change happened to share with it. See
transactions.
Swapping the store
Section titled “Swapping the store”<?php
Audit::setStore(new Store('reporting', 'audit_log'));Audit::setStore(null); // back to the configured one, rebuilt on next usestore() builds one from $config['audit'] on first use and memoises it; setStore()
replaces it, and null clears the memo so the next call rebuilds from configuration. The
third line below is (new Store('default', 'audit_log'))->connection(), which is the only
thing connection() does:
class: StaticPHP\Utils\Models\Audit\StoretableFor(): audit_logconnection: defaultafter setStore(): history_catalogafter setStore(null): audit_logThis is for tests and for an application that assembles its own store. Everything about
Store itself is on storage.
diff()
Section titled “diff()”<?php
public static function diff(?array $before, ?array $after, array $exclude = []): array;A passthrough to Diff::between(), returning the [old, new] pair. It is here so that an
application computing a change itself - one that did not go through Db at all - can apply
the same rules before handing the result to record():
<?php
Audit::diff( ['name' => 'Anna', 'password' => 'a'], ['name' => 'Anna Berzina', 'password' => 'b'], ['password']);[{"name":"Anna","password":"***"},{"name":"Anna Berzina","password":"***"}]See diffing.