laravel-chronicle/core
Cryptographically verifiable audit ledger for Laravel. Append-only entries are hash-chained so tampering, deletion, or reordering is detectable—built for security logging, compliance, and forensic audit trails.
Install the package and run the installer:
composer require laravel-chronicle/core
php artisan chronicle:install
The installer publishes config/chronicle.php and the migrations, and offers to run them. Confirm the hard requirements up front — this is not a pure-PHP package:
php -m | grep -E 'sodium|openssl'
PHP ^8.2, Laravel ^12/^13, ext-sodium (Ed25519) and ext-openssl (ECDSA P-256) are mandatory. Your CI image and any queue worker containers need those extensions too, or tests will fail on the first checkpoint/signing call.
Where to look first, in order: config/chronicle.php (signing ring, references, anchoring), the Chronicle facade, and Chronicle\Entry\Entry. Set up your signing key before anything else — the ledger records fine without it, but checkpoints, exports and compliance reports all sign, and a missing/mis-typed key surfaces at signing time rather than at boot. Generate the keypair, put CHRONICLE_PRIVATE_KEY / CHRONICLE_PUBLIC_KEY in your secret store, and never leave the private key in a committed .env.
First use case: pick one high-value domain event — order.created, user.role_changed, payment.refunded — and record it from the service layer that performs the write:
Chronicle::record()
->actor($user)
->action('order.created')
->subject($order)
->metadata(['total' => 1000, 'currency' => 'USD'])
->tags(['orders', 'billing'])
->commit();
Then, in the same PR, schedule php artisan chronicle:checkpoint in routes/console.php — a ledger with no checkpoints gives you tamper detection on the entries themselves but no signed anchor to prove the head to a third party.
Record at the domain boundary, not the controller. The actor/action/subject triple is the audit contract; put it in the service or an event listener so every entry path (HTTP, queue, CLI, admin action) produces the same shape. metadata should be small and stable — it's part of the hashed payload, so treat its schema as an API you own.
Automatic model auditing is the fast path for the models you control:
class Invoice extends Model
{
use Chronicle\Eloquent\HasChronicle;
}
created/updated/deleted are recorded with a structured diff. For vendor models or models you don't want to touch, Chronicle::observe(Invoice::class) gives the same coverage. In practice you end up with both: the trait for CRUD-level traceability, explicit record() calls for meaningful business events that deserve a named action and tags.
Querying. The scopes are indexed and compose naturally:
Entry::forSubject($order)->action('order.refunded')->latest()->get();
Entry::withTag('billing')->cursorPaginateLedger(50);
Entry::stream()->each(fn ($entry) => $exporter->row($entry)); // constant memory
For any admin UI, prefer resolveReference() / referenceLabel() — they honour the morph map and hit no database — and only opt into referenceModel()/hydrate: true for the rows you actually render. That distinction is the difference between a timeline page that loads in 40ms and one that fires two queries per row.
Operational cadence. A sensible production shape:
chronicle:checkpoint every 5–60 minutes (and after any bulk import).chronicle:verify --checkpoints-only --anchors on a schedule — O(checkpoints), cheap enough to run hourly — as a canary.chronicle:verify full, nightly/off-peak on small ledgers; --since-last-checkpoint or --resume on large ones.chronicle:anchor:retry if anchoring is enabled, so transient TSA failures don't pile up.Wire the exit codes into your monitoring; a failing verify is a security incident, not a warning.
Key rotation runbook — rehearse this before you need it:
php artisan chronicle:key:generate --id=chronicle-key-2
# add the printed entry to signing.keys
php artisan chronicle:key:rotate chronicle-key-2 # boundary checkpoint at current head
# set CHRONICLE_ACTIVE_KEY=chronicle-key-2, deploy
The boundary checkpoint is what makes the epoch handover itself verifiable. When you retire a key, delete only its private_key — keep public_key in the ring forever, or every artifact it ever signed stops verifying.
Testing. Don't build fixture ledgers with record()->commit() in a loop, and never with raw factories. Use the shipper:
$seeded = LedgerSeeder::make()
->count(1000)
->checkpointEvery(100)
->action(fn (int $i) => "order.$i")
->subject(fn (int $i) => Order::factory()->create())
->seed();
It drives the real write path inside one transaction, so the result passes both IntegrityVerifier::verify() and CheckpointChainVerifier::verify() — which raw inserts will not.
DB::table('chronicle_entries')->insert(...) or a factory bypasses canonicalization, hashing and chaining. Everything looks fine until the first chronicle:verify, which then fails on rows you can't fix without truncating. If you need bulk data, that's what LedgerSeeder is for.metadata unless your erasure story is settled; use Chronicle's GDPR erasure path (which is designed to preserve verifiability) rather than DELETE. Audit your metadata schema for anything you might later be asked to remove.chronicle:checkpoints:backfill once after upgrading.--checkpoints-only --anchors for liveness and keep the full walk on a nightly schedule; --resume exists precisely because it can be interrupted.record() block tight, and load-test the write path — not just the read path — before you rely on it in a hot loop.record() runs in a job, the entry lands at commit time, not at event time, and a job that exhausts its retries leaves a hole in the audit history. For high-value events, record synchronously and let slower side effects be queued.signing.active must exist in signing.keys. A typo fails at signing time, deep in a scheduled command, not at boot. Assert it in a config test.signing.active + signing.keys shape — it's the only form that survives a rotation cleanly.Chronicle\Contracts\ReferenceLookup, write a custom signing provider (KMS/HSM/GCP/Vault — sign remotely, verify locally against a cached public key to keep verification offline), or a custom anchor sink. The official companions (kms-aws, anchor-s3 with Object Lock) cover the two most common compliance asks; reach for those before writing your own.How can I help you explore Laravel packages today?