yahyaerturan / audit-codeigniter4
CodeIgniter 4 adapter for the yahyaerturan/audit immutable audit-trail library.
Package info
github.com/yahyaerturan/audit-codeigniter4
pkg:composer/yahyaerturan/audit-codeigniter4
Requires
- php: ^8.5
- ext-json: *
- codeigniter4/framework: ^4.7
- yahyaerturan/audit: ^2.0
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
CodeIgniter 4 integration for the framework-neutral yahyaerturan/audit immutable
audit-trail library. The adapter holds all CI4 coupling so the core package stays free of any
framework dependency.
- Runs audit SQL on your application's own CI4 connection for a configured database group — no hidden second PDO handle, so an append can join your surrounding transaction.
- Auto-discovers its services, config class, Spark commands, and an opt-in request filter.
- Ships safe defaults: anonymous/system actor, no tenancy, request IP/user-agent capture off, and no silent error swallowing.
Requirements
| Requirement | Version |
|---|---|
| PHP | ^8.5 |
| CodeIgniter | codeigniter4/framework ^4.7 |
| Core library | yahyaerturan/audit ^2.0 (installed automatically) |
| Extensions | ext-json |
| Database | SQLite3 or PostgreSQL (see Supported databases) |
The PHP floor is deliberately narrower than CodeIgniter's own: CI4 4.7 supports PHP 8.2+, whereas
this adapter and the core library require PHP 8.5. The constraints still intersect cleanly
(^8.5 ∩ ^8.2 → >=8.5 <9.0), but an application still running PHP 8.3 or 8.4 cannot install
2.0.0. For that case 1.0.0 remains published on the ^8.3 floor, and Composer selects it
automatically when the platform does not satisfy ^8.5.
Installation
composer require yahyaerturan/audit-codeigniter4
On PHP 8.3 or 8.4, pin the last release built for that floor (it pulls the core at ^1.0):
composer require yahyaerturan/audit-codeigniter4:^1.0
The package is discovered automatically through Composer's PSR-4 map: CI4 finds its
Config\Services, Config\Registrar, and Commands without any manual registration, as long as
module discovery stays enabled (the framework default).
Configuration
All knobs live on YahyaErturan\Audit\CodeIgniter4\Config\Audit. They are intentionally coarse and
scalar — richer behavior (actor resolution, tenancy, redaction) is customized by overriding a
service, not by growing this class.
| Property | Type | Default | Purpose |
|---|---|---|---|
databaseGroup |
string |
default |
CI4 database group whose connection the audit store wraps. Use your app's group to couple writes to its transaction, or a dedicated group for independent persistence. |
captureRequestMethod |
bool |
true |
Capture the request.method metadata key on HTTP requests. |
captureRequestPath |
bool |
true |
Capture request.path (query string excluded) on HTTP requests. |
captureIpAddress |
bool |
false |
Capture request.ip. Off by default for privacy. |
captureUserAgent |
bool |
false |
Capture request.user_agent. Off by default for privacy. |
acceptIncomingCorrelationId |
bool |
false |
Trust a validated upstream correlation header. Read only by the opt-in filter; never trusted implicitly. |
incomingCorrelationHeader |
string |
X-Correlation-ID |
Header read for an incoming correlation ID when the above is true. |
metadataMaxBytes |
int |
65536 |
Maximum serialized metadata bytes (mirrors the core default). |
changesMaxBytes |
int |
262144 |
Maximum serialized changes bytes (mirrors the core default). |
Because the class extends CodeIgniter\Config\BaseConfig, override values through .env (short
prefix audit) or an application Registrar — you do not edit the package file.
.env
# Point the audit store at a dedicated group defined in app/Config/Database.php audit.databaseGroup = auditing audit.captureIpAddress = true
Registrar (app/Config/Registrar.php)
<?php namespace Config; class Registrar { public static function Audit(): array { return [ 'databaseGroup' => 'auditing', 'captureIpAddress' => true, ]; } }
Create the schema
Two Spark commands manage the audit tables on the configured databaseGroup:
php spark audit:schema:migrate # apply pending migrations (idempotent) php spark audit:schema:status # report current/latest/applied/pending versions
audit:schema:migrateis idempotent, prints the database group and platform, then either reports the schema is up to date or applies the pending migrations. Exit code0on success,1on failure.audit:schema:statusprints the group, platform, current/latest versions, applied and pending migrations, and whether the schema is up to date. Exit code0, or1if the status cannot be read.
Recording an entry
Resolve the recorder with service('yahyaAuditRecorder') and submit an immutable AuditRecord:
<?php use YahyaErturan\Audit\Domain\Actor; use YahyaErturan\Audit\Domain\AuditRecord; use YahyaErturan\Audit\Domain\Change; use YahyaErturan\Audit\Domain\Changes; use YahyaErturan\Audit\Domain\Subject; $entry = service('yahyaAuditRecorder')->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'), 'paid_at' => Change::added('2026-09-06T12:00:00+00:00'), ])) ->withMetadata(['channel' => 'stripe', 'amount_cents' => 5000]) ); $id = $entry->id()->toString(); // canonical UUID string
The recorder fills the actor, tenant, correlation ID, request ID, and request metadata from the
current request context, normalizes and redacts changes/metadata, assigns a UUIDv7 id and a
recorded-at timestamp, appends exactly once, and returns the immutable AuditEntry. It retains no
per-request state, so sharing it across sequential requests in a long-running worker cannot leak
one request's actor/tenant/trace into the next.
AuditRecord is a fluent, immutable builder — every method returns a new instance. Available
methods: for(), by(), on(), withChanges(), withMetadata(), withTenantId(),
withCorrelationId(), withCausationId(), withRequestId(), occurredAt().
Reading and querying
Resolve the reader with service('yahyaAuditReader'):
<?php use YahyaErturan\Audit\Domain\AuditId; use YahyaErturan\Audit\Query\AuditQuery; $reader = service('yahyaAuditReader'); // By exact id $entry = $reader->find(AuditId::fromString('01890b1e-7c3a-7a10-9f3d-2b6f0a5c1d42')); // Paged search — keyset pagination, canonical order occurred_at DESC, id DESC $page = $reader->search( AuditQuery::all() ->forTenant('acme') ->forEvent('invoice.paid') ->byActor('user', '42') ->limit(50) ); foreach ($page->entries() as $row) { // $row is an AuditEntry } if ($page->hasMore()) { $next = $reader->search( AuditQuery::all()->forTenant('acme')->after($page->nextCursor()) ); }
There is deliberately no count() and no offset pagination: totals are expensive on very large
audit tables and encourage offset UX. AuditPage exposes entries(), nextCursor(), and
hasMore(); pass the opaque cursor back through after() unchanged.
AuditQuery modifiers: forTenant() / withoutTenant() / anyTenant(), forEvent() /
eventPrefix(), byActor(), forSubject(), withCorrelationId() / withCausationId() /
withRequestId(), since() / until(), forIds(), limit() (1–500, default 100), after().
Transaction semantics
The store never owns the business transaction. append() never begins, commits, or rolls back
on its own — it executes its insert on the connection it is given. Point databaseGroup at the same
group your application uses and the audit append becomes part of your transaction:
<?php $db = db_connect(); // same group as Config\Audit::$databaseGroup ('default') $db->transStart(); $invoices->markPaid($invoiceId); // your business mutation service('yahyaAuditRecorder')->record( // the audit fact \YahyaErturan\Audit\Domain\AuditRecord::for('invoice.paid') ->on(\YahyaErturan\Audit\Domain\Subject::identified('invoice', (string) $invoiceId)) ); $db->transComplete(); // The audit row exists if and only if the business mutation committed.
Use a dedicated group when the audit record must survive even if the business transaction rolls back (independent persistence). Both the rollback and the commit case are covered by the adapter's integration tests.
Actor and tenant resolution
The adapter never depends on Shield or any authentication library. Out of the box:
- Actor — a CLI request resolves to
Actor::system('cli'); an HTTP (or unresolved) request resolves toActor::anonymous(). Attributing an action to a real principal requires an application-supplied resolver. - Tenant —
NullTenantResolverinfers no tenant, so records are stored unscoped.
Attribute entries to real users/tenants by overriding the matching service in your application's
app/Config/Services.php (CI4 discovers it with application-namespace precedence):
<?php namespace Config; use CodeIgniter\Config\BaseService; use YahyaErturan\Audit\CodeIgniter4\ActorResolver; class Services extends BaseService { public static function yahyaAuditActorResolver(bool $getShared = true): ActorResolver { return new \App\Audit\ShieldActorResolver(/* inject whatever you need */); } }
Your resolver implements ActorResolver::resolve(): Actor and receives nothing from the framework
automatically — inject your own dependencies (for example Shield's auth()) and return an Actor.
For tenancy, override yahyaAuditTenantResolver (returning TenantResolver::resolve(): ?string),
or set a tenant per record with ->withTenantId('acme').
Optional request-context filter (audit-context)
Installing the package registers the audit-context filter alias (via its Registrar), but the
filter is never global and never runs at boot. Attach it explicitly to routes or groups to stamp
each HTTP request with adapter-private trace markers before your application runs:
// app/Config/Filters.php public array $globals = [ 'before' => ['audit-context'], ]; // or per route group $routes->group('api', ['filter' => 'audit-context'], static function ($routes) { // ... });
The filter writes two spoof-proof headers — it removes each header before re-setting it, so a client cannot inject or duplicate them:
| Header | Value |
|---|---|
X-Yahya-Audit-Request-Id |
a fresh UUIDv7 per request |
X-Yahya-Audit-Correlation-Id |
a fresh UUIDv7, or a validated upstream value when acceptIncomingCorrelationId is true and incomingCorrelationHeader is present and valid |
The context provider later reads these markers into each recorded entry's requestId /
correlationId. The filter records nothing and never mutates the response. Without the filter the
recorder still works — those two identifiers are simply null.
Supported databases
| CI4 platform | Core driver | Status |
|---|---|---|
SQLite3 |
sqlite |
supported |
Postgre |
postgresql |
supported |
any other (MySQLi, SQLSRV, OCI8, …) |
— | rejected at construction with UnsupportedDatabaseDriver |
The bridge issues raw SQL with positional ? binds and never uses the query builder, so the group's
DBPrefix is not applied to audit statements — the schema owns its table names.
Failure behavior
Nothing is silently swallowed. The adapter translates CI4's two failure modes (a thrown
DatabaseException, or query() returning false with a connection error) into core audit
exceptions, all of which implement the AuditException marker:
| Condition | Exception |
|---|---|
| Unsupported database platform | UnsupportedDatabaseDriver |
| Transaction begin/commit/rollback failed | AuditPersistenceFailed |
| Insert/statement failed | AuditPersistenceFailed |
| Duplicate entry id (unique violation) | DuplicateAuditEntryId |
| Invalid value (oversize metadata/changes, bad ids) | InvalidAuditValue / InvalidAuditIdentifier |
| Invalid query | InvalidAuditQuery |
| Invalid cursor | InvalidAuditCursor |
Because the store never owns the transaction, a thrown exception leaves the surrounding transaction for your code to roll back.
Services reference
Resolve any service with service('...'). Each is shared and safe to reuse across requests.
| Service key | Contract | Responsibility |
|---|---|---|
yahyaAuditRecorder |
Auditor |
record(AuditRecord): AuditEntry |
yahyaAuditReader |
AuditReader |
find() / search() |
yahyaAuditStore |
AuditStore |
append-only writer |
yahyaAuditSqlConnection |
SqlConnection |
wraps the configured group's connection |
yahyaAuditSchemaManager |
AuditSchemaManager |
schema migrate/status |
yahyaAuditContextProvider |
AuditContextProvider |
builds the per-request context |
yahyaAuditActorResolver |
ActorResolver |
override to attribute real users |
yahyaAuditTenantResolver |
TenantResolver |
override for tenancy |
yahyaAuditRequestAccessor |
RequestAccessor |
current-request seam |
Service names are yahyaAudit*-prefixed because CI4 discovers service methods globally and
unprefixed names would collide across packages.
License
MIT — see LICENSE.