Auditing
pop-audit records the difference between two states of a model, along with the user and request that
caused it, and stores that in files, a database table or a remote HTTP service. Nothing hooks itself into a
save — you call the auditor at the point in your own code that knows a change happened.
composer require popphp/pop-audit
What Auditing Captures#
One record has three parts: who and where, what changed, and what the record looked like afterward.
Pop\Audit\Auditor collects the first, diffs the second out of the two arrays you hand it, and snapshots
the third.
use Pop\Audit\Adapter\File;
use Pop\Audit\Auditor;
$auditor = new Auditor(new File(__DIR__ . '/../audit'));
$auditor->setModel('App\Model\User', 1001);
$auditor->setUser('testuser', 101);
$auditor->setDomain('users.localhost', '/users/edit/1001', 'POST');
$auditor->addMetadata('ip_address', '127.0.0.1');
The model name and its ID are the only required data points — everything else is optional context.
setUser() takes a username and a user ID, setDomain() takes a domain and optionally the route and HTTP
method, and setMetadata() or addMetadata() attach arbitrary key/value pairs for anything the built-in
fields do not cover.
Hand send() the before and after arrays and it works out the rest:
use Pop\Audit\Adapter\File;
use Pop\Audit\Auditor;
$auditor = new Auditor(new File(__DIR__ . '/../audit'));
$auditor->setModel('App\Model\User', 1001);
$auditor->setUser('testuser', 101);
$old = ['id' => 1, 'username' => 'admin', 'email' => 'test@test.com', 'phone' => '504-555-5555'];
$new = ['id' => 1, 'username' => 'admin2', 'email' => 'test@test.com', 'phone' => '504-555-6666'];
$auditor->send($old, $new);
The stored record keeps only the keys that moved — username and phone here, not id or email —
plus a snapshot of the whole post-change state:
{
"user_id": 101,
"username": "testuser",
"domain": null,
"route": null,
"method": null,
"model": "App\\Model\\User",
"model_id": 1001,
"action": "updated",
"old": { "username": "admin", "phone": "504-555-5555" },
"new": { "username": "admin2", "phone": "504-555-6666" },
"state": { "id": 1, "username": "admin2", "email": "test@test.com", "phone": "504-555-6666" },
"metadata": {},
"timestamp": "2026-08-24 09:46:47"
}
action is derived from which half of the diff is empty: an empty old is created, an empty new is
deleted, anything else is updated. Pass false as the third argument to send() to skip the state
snapshot and keep only the diff.
Two states that do not differ produce nothing at all. send() returns false and writes no record, so
a save that changed nothing does not leave an empty row behind.
Auditing Record Changes#
Building the before and after arrays by hand gets old fast, and Pop\Db\Record already tracks them for
you. A record marks a column dirty the moment you assign to it, and getDirty() returns exactly the
old/new pair the auditor wants.
use Pop\Audit\Adapter\File;
use Pop\Audit\Auditor;
use Pop\Db\Record;
class AuditedUser extends Record {}
$user = AuditedUser::findById(1001);
$user->username = 'admin2';
$dirty = $user->getDirty(); // ['old' => ['username' => 'admin'], 'new' => ['username' => 'admin2']]
$user->save();
$auditor = new Auditor(new File(__DIR__ . '/../audit'));
$auditor->setModel(AuditedUser::class, (int)$user->id);
$auditor->setDiff($dirty['old'], $dirty['new']);
$auditor->setStateData($user->toArray());
$auditor->send();
setDiff() is the entry point for a diff you computed elsewhere — pass it the two halves and call
send() with no arguments. setStateData() is the matching call for the full snapshot, and is optional:
leave it out and the record keeps only the diff.
isDirty() is the guard for skipping the whole block when nothing moved.
Dirty tracking accumulates across saves on one record instance, so send the audit after each save and fetch a fresh record when you want a clean slate.
If you would rather the model own its auditor than pass one around, Pop\Audit\Model\AuditableModel and
its Pop\Audit\Model\AuditableInterface hold the reference for you:
use Pop\Audit\Adapter\File;
use Pop\Audit\Auditor;
use Pop\Audit\Model\AuditableModel;
class AuditedUserModel extends AuditableModel
{
public function rename(int $id, array $old, array $new): void
{
if ($this->isAuditable()) {
$this->getAuditor()->setModel(static::class, $id);
$this->getAuditor()->send($old, $new);
}
}
}
$model = new AuditedUserModel();
$model->setAuditor(new Auditor(new File(__DIR__ . '/../audit')));
That's the whole of it — setAuditor(), getAuditor(), hasAuditor() and isAuditable(). No model
lifecycle event calls the auditor for you; deciding when auditing fires stays in your code.
Pop\Audit\Model\AuditableModel extends Pop\Db\Model\AbstractDataModel.
Database and HTTP Adapters#
Three adapters implement Pop\Audit\Adapter\AdapterInterface, and swapping between them is a change to
the Auditor constructor and nowhere else.
Pop\Audit\Adapter\Table takes the class name of a Pop\Db\Record subclass and writes one row per
change:
use Pop\Audit\Adapter\Table;
use Pop\Audit\Auditor;
use Pop\Db\Db;
use Pop\Db\Record;
class AuditLog extends Record {}
AuditLog::setDb(Db::connect('sqlite', ['database' => __DIR__ . '/../audit.sqlite']));
$auditor = new Auditor(new Table(AuditLog::class));
$auditor->setModel('App\Model\User', 1001);
$auditor->setUser('testuser', 101);
$row = $auditor->send(['username' => 'admin'], ['username' => 'admin2']);
send() hands back the newly created record, so $row->id is the audit ID to keep alongside whatever
triggered the change. The table name follows the usual Record convention — AuditLog becomes
audit_log — and the adapter creates it on first use if it is not there, which means the database user
needs CREATE TABLE. Where that is not acceptable, reference schemas for MySQL, PostgreSQL and SQLite
ship at vendor/popphp/pop-audit/src/Adapter/Sql/ and can be applied as a migration instead.
Pop\Audit\Adapter\Http posts each record to a service you run. It takes a configured
Pop\Http\Client, so the URL, method and credentials are the client's concern:
use Pop\Audit\Adapter\Http;
use Pop\Audit\Auditor;
use Pop\Http\Auth;
use Pop\Http\Client;
$sendClient = new Client('https://audit.mydomain.com/', Auth::createBearer('AUTH_TOKEN'), ['method' => 'POST']);
$auditor = new Auditor(new Http($sendClient));
$auditor->setModel('App\Model\User', 1001);
$auditor->setUser('testuser', 101);
$response = $auditor->send(['username' => 'admin'], ['username' => 'admin2']);
send() returns the Pop\Http\Client\Response, so a non-2xx status is yours to inspect — the adapter
does not raise on one. The body is form-encoded with user_id, username, domain, model,
model_id, action, old, new, state, metadata and timestamp; old, new, state and
metadata arrive as JSON strings inside those fields.
A second, optional client handles reads, so a write-only sink never needs read credentials configured:
use Pop\Audit\Adapter\Http;
use Pop\Audit\Auditor;
use Pop\Http\Auth;
use Pop\Http\Client;
$sendClient = new Client('https://audit.mydomain.com/', Auth::createBearer('AUTH_TOKEN'), ['method' => 'POST']);
$fetchClient = new Client('https://audit.mydomain.com/', Auth::createBearer('AUTH_TOKEN'), ['method' => 'GET']);
$auditor = new Auditor(new Http($sendClient, $fetchClient));
Give an Http adapter both clients when you intend to read as well as send.
The Http adapter also defines only the client side of the exchange. pop-audit does not specify a wire
protocol, so the service on the far end has to be written to match the fields above and the filter shapes
the read methods build.
Reading the Audit Trail#
Reading goes through adapter(). Every adapter implements the same six methods, though their parameters
differ — File sorts and pages, Table takes pop-db findBy()-style columns and options, and Http
takes field arrays for your service to interpret.
| Method | Returns |
|---|---|
getStates() |
Every stored record |
getStateById($id) |
One record by its own audit ID |
getStateByModel($model, $modelId) |
Every record for one model instance |
getStateByTimestamp($from, $backTo) |
Records in a Unix-timestamp range |
getStateByDate($from, $backTo) |
Records in a Y-m-d or Y-m-d H:i:s range |
getSnapshot($id, $post) |
One half of one record — before, or after |
getSnapshot() is the one to reach for when recovering a value. It returns the old half by default and
the new half when the second argument is true:
use Pop\Audit\Adapter\Table;
use Pop\Audit\Auditor;
use Pop\Db\Db;
use Pop\Db\Record;
class AuditRecord extends Record {}
AuditRecord::setDb(Db::connect('sqlite', ['database' => __DIR__ . '/../audit.sqlite']));
$auditor = new Auditor(new Table(AuditRecord::class));
$before = $auditor->adapter()->getSnapshot(1); // ['username' => 'admin']
$after = $auditor->adapter()->getSnapshot(1, true); // ['username' => 'admin2']
Everything ever recorded for one model instance is getStateByModel():
use Pop\Audit\Adapter\File;
use Pop\Audit\Auditor;
$auditor = new Auditor(new File(__DIR__ . '/../audit'));
$history = $auditor->adapter()->getStateByModel('App\Model\User', 1001);
getStateByModel() requires the model ID on the File adapter — passing null throws
Pop\Audit\Adapter\Exception with "You must pass a model ID."
The two adapters also disagree about what they decode. File returns old, new, state and
metadata as PHP arrays; Table decodes old and new but hands back state and metadata as the
raw JSON strings stored in those columns, so json_decode() those two yourself when reading from a
table.
Adapter-specific parameters — File's sort and paging, Table's columns and options, the filter shapes
Http builds — go past what this page covers; see the pop-audit README.
See Also#
- Records & the ORM —
getDirty(),isDirty()and where the diff comes from - Logging — the record of what happened, as opposed to the record of what changed
- File Storage — where a
Fileadapter's audit folder might live - pop-audit README — the full adapter API, method by method