yahyaerturan / audit
Immutable semantic audit-trail library for PHP. Framework-independent, portable across SQLite and PostgreSQL.
Requires
- php: ^8.5
- ext-json: *
- ext-mbstring: *
- psr/clock: ^1.0
- ramsey/uuid: ^4.7
Requires (Dev)
- phpstan/phpstan: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
An immutable, semantic audit-trail library for PHP. Framework-independent, portable across SQLite and PostgreSQL, and designed so a recorded fact is faithful, queryable, and never silently altered or swallowed.
An audit entry answers who did what to which thing, when, in what context, and what changed — as a durable business fact, not as a line of text in a log file.
Why an audit trail is not a log
| Logging | This audit trail | |
|---|---|---|
| Shape | Free-form text lines | Structured, validated domain facts |
| Identity | None | A UUIDv7 id per entry, generated before insert |
| Mutability | Rotated, truncated, overwritten | Append-only; no update or delete API |
| Query | grep |
Typed criteria + keyset pagination over indexed dimensions |
| Contract | PSR-3 log levels | No log levels; an explicit record() call |
There is deliberately no PSR-3 implementation and no log-level API (see ADR-001). Audit records are evidence, and evidence has structure, identity, and integrity requirements that log lines do not.
Why an audit trail is not event sourcing
Event sourcing rebuilds application state by replaying a full, ordered event stream; the stream is the source of truth. This library does the opposite: your database remains the source of truth, and the audit trail records semantic facts about changes you chose to make explicit. There is no projection, no replay-to-derive-state, and no requirement that every state change emit an event. You decide what is worth recording, and you record it in the same transaction as the business change when you want them to stand or fall together.
Design guarantees
- Immutable, append-only.
AuditStoreexposesappend()only — no ordinary update or delete (ADR-004). Entries are fully validated at construction and expose no setters. - Framework-free core. The core package has zero framework symbols; framework coupling lives in separate adapter packages (ADR-002).
- Portable persistence. Shared SQL logic targets a tiny connection port; only indexed dimensions are columns and flexible payloads are JSON — there is no arbitrary JSON-query API (ADR-005, ADR-006).
- Deterministic seams. Time comes from a PSR-20 clock and ids from a generator contract, so recording is testable and reproducible (ADR-007, ADR-009).
- Redaction before persistence. Sensitive keys are masked before an entry is ever written (ADR-011).
- Caller owns the transaction.
append()never begins, commits, or rolls back (ADR-012). - No silent failure. Every error propagates as a typed exception; nothing is caught-and-logged.
Requirements
| Requirement | Version |
|---|---|
| PHP | ^8.5 |
| Extensions | ext-json, ext-mbstring, plus pdo_sqlite and/or pdo_pgsql |
| Databases | SQLite 3, PostgreSQL 13+ |
| Libraries | psr/clock ^1.0, ramsey/uuid ^4.7 (installed automatically) |
Still on PHP 8.3 or 8.4? Use the 1.0.x line, whose last release is 1.0.1:
composer require yahyaerturan/audit:^1.0
Installation
composer require yahyaerturan/audit
Quick start (SQLite)
<?php use YahyaErturan\Audit\Domain\Actor; use YahyaErturan\Audit\Domain\AuditLimits; use YahyaErturan\Audit\Domain\AuditRecord; use YahyaErturan\Audit\Domain\Change; use YahyaErturan\Audit\Domain\Changes; use YahyaErturan\Audit\Domain\Subject; use YahyaErturan\Audit\Normalization\NormalizerChain; use YahyaErturan\Audit\Persistence\Sql\Connection\PdoSqlConnection; use YahyaErturan\Audit\Persistence\Sql\Schema\SqlAuditSchemaManager; use YahyaErturan\Audit\Persistence\Sql\SqlAuditReader; use YahyaErturan\Audit\Persistence\Sql\SqlAuditStore; use YahyaErturan\Audit\Query\AuditQuery; use YahyaErturan\Audit\Recording\DefaultAuditor; use YahyaErturan\Audit\Recording\NullAuditContextProvider; use YahyaErturan\Audit\Recording\SystemClock; use YahyaErturan\Audit\Recording\UuidV7AuditIdGenerator; use YahyaErturan\Audit\Redaction\RedactionPipeline; // 1. Wrap a PDO connection (create PDO in exception mode). $pdo = new PDO('sqlite:/var/lib/app/audit.sqlite'); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $connection = PdoSqlConnection::fromPdo($pdo); // 2. Create the schema with the package-owned migrations (idempotent). (new SqlAuditSchemaManager($connection))->migrate(); // 3. Build the recorder from its explicit, injectable dependencies. $limits = AuditLimits::defaults(); $auditor = new DefaultAuditor( store: SqlAuditStore::create($connection), clock: new SystemClock(), idGenerator: new UuidV7AuditIdGenerator(), contextProvider: new NullAuditContextProvider(), normalizer: NormalizerChain::withDefaults($limits), redactor: RedactionPipeline::withDefaults(), limits: $limits, ); // 4. Record a semantic fact. $entry = $auditor->record( AuditRecord::for('invoice.paid') ->by(Actor::identified('user', '42', 'Ada Lovelace')) ->on(Subject::identified('invoice', 'INV-123', 'March invoice')) ->withChanges(Changes::from([ 'status' => Change::replaced('open', 'paid'), ])) ->withMetadata(['channel' => 'stripe', 'amount_cents' => 5000]) ); // 5. Read it back. $reader = SqlAuditReader::create($connection); $found = $reader->find($entry->id()); // ?AuditEntry $page = $reader->search(AuditQuery::all()->limit(50));
PostgreSQL
The API is identical — only the PDO DSN changes. The driver is detected from PDO
(pgsql); the schema manager then applies the PostgreSQL migrations automatically.
$pdo = new PDO( 'pgsql:host=localhost;port=5432;dbname=app', 'app_user', 'secret', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], ); $connection = PdoSqlConnection::fromPdo($pdo); (new SqlAuditSchemaManager($connection))->migrate();
PostgreSQL entries use TIMESTAMPTZ(6) timestamps and JSONB payloads; SQLite uses fixed-width UTC
text timestamps (so ordering is lexicographic) and canonical JSON text validated by json_valid().
Only the sqlite and pgsql PDO drivers are supported — anything else fails fast with
UnsupportedDatabaseDriver.
Recording
An AuditRecord is an immutable, fluent description of an action. Every builder method returns a new
instance:
AuditRecord::for('customer.email.changed') // semantic, lowercase, dot-separated event name ->by(Actor::identified('user', '42')) // or Actor::system('scheduler'), Actor::anonymous() ->on(Subject::identified('customer', 'C-9')) // optional; omit when there is no resource identity ->withChanges(Changes::from([ 'email' => Change::replaced('a@x.io', 'b@x.io'), 'phone' => Change::added('+15551234567'), 'fax' => Change::removed('old-fax'), ])) ->withMetadata(['reason' => 'user_request']) ->withTenantId('acme') // optional tenancy dimension ->occurredAt($someDateTime); // optional; defaults to the clock's now()
Change operations preserve the difference between absent and present with null: added(null),
removed(null), and replaced($from, $to) are distinct, and replaced() rejects an identical
from/to (ADR-008). Event names must match ^[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)*$ (ADR-003);
uppercase or otherwise invalid names are rejected, never silently normalized.
Values are normalized (backed enums and date/time values are handled; everything else must be a
strict JSON value), then redacted, then size-checked against AuditLimits before persistence.
Reading and querying
$page = $reader->search( AuditQuery::all() ->forTenant('acme') ->forEvent('invoice.paid') // or ->eventPrefix('invoice.') ->byActor('user', '42') ->since(new DateTimeImmutable('2026-01-01')) ->limit(100) // 1..500, default 100 ); foreach ($page->entries() as $entry) { // $entry is an immutable AuditEntry } if ($page->hasMore()) { $next = $reader->search(AuditQuery::all()->forTenant('acme')->after($page->nextCursor())); }
Results are ordered occurred_at DESC, id DESC and paginated by an opaque keyset cursor — there
is no offset API and no total count(), because both are expensive on very large audit tables and
encourage fragile pagination (ADR-010). AuditPage exposes entries(), nextCursor(), and
hasMore(); pass the cursor back through after() unchanged.
Query criteria: forTenant() / withoutTenant() / anyTenant(), forEvent() / eventPrefix(),
byActor(), forSubject(), withCorrelationId() / withCausationId() / withRequestId(),
since() / until(), forIds(), limit(), after().
Schema migrations
The package owns its schema and never relies on a host framework's migrator. Migrations are versioned
SQL assets under resources/sql/<driver>/NNN_name.sql, applied in ascending order; each runs inside
its own transaction on your connection, so a failure records no version and leaves prior migrations
intact.
$manager = new SqlAuditSchemaManager($connection); $status = $manager->status(); // driver, applied/pending versions, current/latest, isUpToDate() $result = $manager->migrate(); // appliedVersions(), currentVersion(), nothingApplied()
Two tables are created: audit_entries (the facts) and audit_schema_migrations (the version
ledger). The audit_entries table carries indexes for the time, tenant+time, subject, actor, event,
and correlation dimensions. See docs/persistence.md for the full schema.
CodeIgniter 4 users get
php spark audit:schema:migrateandphp spark audit:schema:statuswrappers around this same manager — see the adapter documentation below.
Transaction semantics
Normative. The store never owns the business transaction:
append()does not begin, commit, or roll back. It executes its singleINSERTon the connection you supply, so it participates in whatever transaction that connection is already in (ADR-012).
Use the same connection when the audit record must exist if and only if the business mutation commits:
$connection->beginTransaction(); $invoiceRepository->markPaid($invoiceId); // your business change $auditor->record(AuditRecord::for('invoice.paid')->on($subject)); $connection->commit(); // or rollBack() — the audit row follows
Use a separate connection (a dedicated database) when the audit record must survive even if the business transaction rolls back. Both behaviors are covered by the test suite.
Redaction and privacy
Redaction runs before persistence, so a masked value is never written to the database in the
first place (ADR-011). The default pipeline masks a conservative set of clearly sensitive keys —
password, secret, access_token, refresh_token, authorization, cookie, private_key,
card_number, cvv, and similar — replacing the value with the marker [REDACTED]. Matching is
case-insensitive and by exact key name, so legitimate keys such as foreign_key survive. For a
change, the operation and path are preserved (the fact that something changed is not lost) while the
from/to values are masked.
Warning. Redaction is a safety net, not a substitute for not collecting secrets or PII in the first place. It only masks known key names; a secret stored under an innocuous key will not be caught. Audit entries are durable and append-only: once written, a value cannot be edited or removed through this library. Treat the audit store as sensitive data, restrict access to it, and review what you record. See SECURITY.md.
You can extend the default pipeline with exact paths. Each path is prefixed to say where it applies:
changes.<path> masks the from/to of that exact change path, and metadata.<a.b.c> masks the
addressed leaf of a (possibly nested) metadata map:
RedactionPipeline::withDefaults(['changes.payment.card.pan', 'metadata.user.ssn']);
CodeIgniter 4 adapter
A separate package, in its own repository, wires everything above into CodeIgniter 4 — running audit SQL on your application's own CI4 connection, auto-discovering services, and adding Spark schema commands and an opt-in request-context filter.
composer require yahyaerturan/audit-codeigniter4
php spark audit:schema:migrate; $entry = service('yahyaAuditRecorder')->record( \YahyaErturan\Audit\Domain\AuditRecord::for('invoice.paid') ->on(\YahyaErturan\Audit\Domain\Subject::identified('invoice', 'INV-123')) );
Full details — configuration, actor/tenant resolution, the audit-context filter, transaction
coupling, and the service reference — are in the adapter's own repository,
yahyaerturan/audit-codeigniter4. The core
package never depends on the adapter, and its test suite runs with the adapter absent.
Failure behavior
Nothing is silently swallowed and nothing is caught-and-logged. Failures surface as typed exceptions
that all implement the YahyaErturan\Audit\Exception\AuditException marker:
| Condition | Exception |
|---|---|
| Unsupported PDO driver | UnsupportedDatabaseDriver |
| Statement/transaction failed | AuditPersistenceFailed |
| Duplicate entry id (unique violation) | DuplicateAuditEntryId |
| Invalid identifier (id, event name, type) | InvalidAuditIdentifier |
| Invalid value (bad change, non-string key) | InvalidAuditValue |
| Normalization produced an unsupported value | AuditNormalizationFailed |
| Serialized payload exceeds a limit | AuditPayloadTooLarge |
| Invalid query | InvalidAuditQuery |
| Invalid cursor | InvalidAuditCursor |
Reading a stored entry whose format_version is not supported |
UnsupportedAuditFormat |
| Schema migration failure | AuditSchemaException |
Limits are enforced by throwing — data is never truncated automatically. Because the store never owns the transaction, a thrown exception leaves the surrounding transaction for your code to roll back.
Documentation
- docs/architecture.md — dependency graph, recording pipeline, persistence port, adapter model, append-only semantics.
- docs/persistence.md — schemas, indexes, transaction choices, SQLite and PostgreSQL notes, migrations.
- docs/usage.md — recording, querying, normalization, redaction, and limits in depth.
- docs/adr/ — architecture decision records.
- docs/releasing.md — packaging and release checklist.
- SECURITY.md — threat model, tamper-resistance limits, PII guidance.
- CHANGELOG.md — release history.
License
MIT — see LICENSE.