Pop PHP
The Toolkit

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.

BASH
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.

PHP
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:

PHP
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:

JSON
{
    "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.

PHP
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:

PHP
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:

PHP
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:

PHP
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:

PHP
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:

PHP
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():

PHP
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 ORMgetDirty(), 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 File adapter's audit folder might live
  • pop-audit README — the full adapter API, method by method