Search by

Immutable semantic audit-trail library for PHP. Framework-independent, portable across SQLite and PostgreSQL.

2.0.0 2026-09-08 09:02 UTC

This package is auto-updated.

Last update: 2026-09-08 14:36:20 UTC


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. AuditStore exposes append() 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:migrate and php spark audit:schema:status wrappers 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 single INSERT on 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.