laravel-chronicle/core
Chronicle provides cryptographically verifiable audit logging for Laravel. It records events in an append-only, hash-chained ledger to make tampering detectable, with features like verifiable exports, signed checkpoints, key rotation, and external anchoring.
Chronicle 1.13.0 opens up the package's public surface for host applications and downstream packages. It adds four capabilities - a configurable entry model, entry-range verification, reverse reference resolution, and a verification-preserving test seeder - and lays the groundwork for the upcoming laravel-chronicle/filament plugin.
This is a drop-in, backward-compatible minor. With the new config keys unset, behavior is byte-for-byte identical to 1.12.x. No breaking changes, no migrations.
Point Chronicle at your own subclass of Chronicle\Entry\Entry to add accessors, relationships, or casts. The override is resolved through a single seam (Chronicle::entryModel() / Chronicle::newEntryQuery()) that is honored everywhere - the manager query, the ledger reader, all three verifiers, the storage drivers, and the entry-touching console commands.
// config/chronicle.php
'models' => [
'entry' => \App\Models\AuditEntry::class,
],
The override must extend Chronicle\Entry\Entry; Chronicle validates this and throws InvalidEntryModelException otherwise, so immutability and the hash-chain contract are always preserved. Chronicle\Entry\Entry is now documented as stable, subclassable public API.
Verify an arbitrary span of entries without working out checkpoint bounds yourself:
php artisan chronicle:verify --from={first-entry-ulid} --to={last-entry-ulid}
app(\Chronicle\Verification\IntegrityVerifier::class)
->verifyEntryRange($fromSequence, $toSequence);
Chronicle resolves the signed checkpoints that enclose the range, verifies their signatures, and recomputes the chain between them - so verification of the requested rows rides on the signed anchors, never on an entry's own stored hash. It fails closed if the derived anchors don't actually enclose the range. Ranges within a single checkpoint segment, spanning several, starting at genesis, and extending past the last checkpoint (recomputed to the head, same trust as --since-last-checkpoint) are all handled.
Turn a stored (type, id) actor/subject back into something displayable. Honors Relation::morphMap() and does not touch the database unless you opt in:
use Chronicle\Facades\Chronicle;
$ref = Chronicle::resolveReference($entry->subject_type, $entry->subject_id);
$ref->class; // resolved FQCN, or null when unknown
$ref->label; // "Order #123" - humanised basename + id
Chronicle::referenceLabel($entry->actor_type, $entry->actor_id); // no query
Chronicle::referenceModel($entry->subject_type, $entry->subject_id); // ?Model (queries)
Chronicle::referenceLabel($entry->subject_type, $entry->subject_id, hydrate: true);
Hydration reads chronicle.references.label_attribute (default name). Bind your own Chronicle\Contracts\ReferenceLookup to fully customise resolution. The storage (write) direction is unchanged.
Seed realistic, verifiable ledger data in tests without one-at-a-time record()->commit() calls or invalid raw factory inserts:
use Chronicle\Testing\LedgerSeeder;
$seeded = LedgerSeeder::make()
->count(1000)
->checkpointEvery(100)
->action(fn (int $i) => "order.$i")
->subject(fn (int $i) => Order::factory()->create())
->seed();
$seeded->entries; // 1000
$seeded->checkpoints; // 10
$seeded->lastCheckpointId; // ?string
LedgerSeeder drives the real write path inside a single transaction and writes periodic signed checkpoints, so the result passes both IntegrityVerifier::verify() and CheckpointChainVerifier::verify(). Ships under the Testing namespace with no impact on production code paths.
composer update laravel-chronicle/core
No breaking changes and no new migrations. The four config keys (chronicle.models.entry, chronicle.references.label_attribute) are optional with safe defaults; leaving them unset reproduces 1.12.x behavior exactly.
^8.2^12.0 or ^13.0ext-sodium, ext-openssl898 tests / 1750 assertions passing, PHPStan level 10 (no new baseline entries), Pint clean.
Full Changelog: https://github.com/laravel-chronicle/core/compare/1.12.1...1.13.0
Full Changelog: https://github.com/laravel-chronicle/core/compare/1.12.0...1.12.1
This release answers the question every compliance team asks about an immutable audit log: how do you honour a GDPR Article 17 erasure request against a ledger designed never to change?
Chronicle 1.12 resolves the tension with crypto-shredding. PII-bearing payload fields are encrypted under a per-subject key before hashing, so the hash chain is computed over ciphertext. To erase a subject, you destroy their key: the ciphertext stays in place — the ledger still verifies, byte-for-byte — but the content becomes permanently unreadable. What remains is the pseudonymised fact that an event happened: the evidence, not the personal data.
Encryption is opt-in. With it disabled, behaviour is identical to 1.11.
chronicle:subject:erase destroys a subject's key and records a verifiable, PII-free subject.erased proof you can show a regulator.chronicle:verify still passes; reads of erased fields return a tombstone, while the cleartext envelope (actor, action, subject, timestamp, tags) stays queryable.laravel-chronicle/kms-aws so it never lives in the app.// config/chronicle.php
'encryption' => [
'enabled' => env('CHRONICLE_ENCRYPTION_ENABLED', false),
'fields' => ['metadata', 'context', 'diff'], // PII-bearing fields, encrypted per-subject DEK
'kek' => [
'provider' => Chronicle\Encryption\LocalKeyEncryptionProvider::class,
'key' => env('CHRONICLE_ENCRYPTION_KEY'), // dedicated base64 32-byte key — NOT the app key
'id' => env('CHRONICLE_ENCRYPTION_KEK_ID', 'local'),
],
],
Each data subject gets a random data key (DEK), wrapped by the key-encryption key (KEK) and stored alongside the ledger. Configured fields are encrypted with the subject's DEK between canonicalisation and hashing, so payload_hash and chain_hash cover the ciphertext. Erasure destroys the DEK; the ciphertext that remains can never be decrypted again.
php artisan chronicle:subject:erase patient 01H... # destroy the DEK; records a PII-free proof
php artisan chronicle:subject:keys --status=erased # inspect key state (never prints key material)
Erasure is idempotent, is itself recorded as a verifiable subject.erased entry (containing no PII), and leaves the ledger fully verifiable. Reads of an erased subject's encrypted fields return a tombstone; the event fact remains.
| Command | Purpose |
|---|---|
chronicle:subject:erase {type} {id} |
Destroy a subject's encryption key (GDPR erasure); records a PII-free proof (--reason) |
chronicle:subject:keys |
Inspect subject key state — never key material (--subject, --status, --json) |
chronicle:legal-hold {action} {type} {id} |
Place / release a litigation hold that blocks erasure and pruning |
chronicle:encryption:rotate-kek |
Re-wrap all subject DEKs under a new KEK (--old-key, --old-kek-id, --chunk) |
chronicle:encrypt-backfill |
Re-baseline migration: encrypt historical entries' PII (--from, --chunk, --dry-run, --force) |
chronicle:legal-hold place {type} {id} prevents both erasure and pruning of a held subject; release lifts it.chronicle:encryption:rotate-kek re-wraps every DEK under a new KEK. It changes no ciphertext, hashes, or signatures, so the ledger is unaffected and remains decryptable.1. No changes required to keep current behaviour. Encryption defaults to off; existing ledgers verify and export exactly as on 1.11. The new migrations add the subject-key and legal-hold tables only.
2. Run the new migrations.
php artisan migrate
3. To enable encryption (forward-only). Generate a dedicated 32-byte base64 key, set CHRONICLE_ENCRYPTION_KEY (do not reuse the app key), and turn on CHRONICLE_ENCRYPTION_ENABLED. New entries are encrypted from then on; existing entries stay cleartext.
4. (Optional) Encrypt historical entries. chronicle:encrypt-backfill re-encrypts existing entries' PII. This is a deliberate re-baselining migration — it recomputes payload_hash, re-links chain_hash to the head, and writes a fresh signed checkpoint. Take a backup first; it is gated behind --dry-run and --force and is not a routine operation.
CHRONICLE_ENCRYPTION_KEY safe and separate. Losing the KEK makes all wrapped DEKs (and therefore all encrypted content) unrecoverable; that is by design.^8.2^12.0 or ^13.0ext-sodium, ext-opensslFull Changelog: https://github.com/laravel-chronicle/core/compare/1.11.0...1.12.0
This release roots checkpoint trust outside the application and makes verifying a large ledger cheap, while hardening the write path against concurrency and tampering. It is fully backward compatible: a 1.10 ledger with anchoring disabled and no incremental flags verifies identically to 1.10, with no artifact-format change and no re-export needed.
sha256(id . chain_hash . created_at) — into an independent trust domain so a full internal compromise is detectable. Ships an RFC 3161 timestamp anchor in core (offline verify, no cloud SDK) and a NullAnchor for dev/tests. A checkpoint that's been rewritten and re-signed with a valid key still fails chronicle:verify --anchors.chronicle:verify modes — --checkpoints-only, --from-checkpoint/--to-checkpoint, --since-last-checkpoint, --resume, and --anchors — trade scope for cost without losing rigor. Each falls back to full verify (with a warning) until checkpoints are backfilled.head_id, entry_count, and previous_checkpoint_id, and checkpoint_id is populated on covered entries at creation — fixing a 1.10 gap where the checkpoint-verification branch never ran. checkpoint_id is unhashed; no signatures change.sequence column (assigned under the chain row-lock, with unique(sequence)/unique(chain_hash)) replaces ULID-sort ordering everywhere — a concurrent chain fork now fails loudly instead of corrupting the ledger.laravel-chronicle/anchor-s3 reference adapter anchors to an S3 Object Lock (WORM) bucket.sequence column on chronicle_entries (assigned under the chain row-lock; unique(sequence) + unique(chain_hash)), with a backfilling migration that's a no-op on fresh installs.IntegrityVerifier::verifyFrom(Checkpoint) — verify from a known-good checkpoint instead of genesis (signature verified before its chain_hash seeds the walk), so pruned-history ledgers stay verifiable.head_id, entry_count, previous_checkpoint_id columns; new chronicle_checkpoint_anchors and optional chronicle_verification_runs tables; chronicle.tables.* keys for both. Checkpoint gains previousCheckpoint() and anchors() relations.CheckpointCreator::create() records head/count/linkage and populates checkpoint_id on covered entries (unhashed — no payload_hash/chain_hash change).chronicle:checkpoints:backfill — chunked, idempotent backfill of the range columns + checkpoint_id for pre-1.11 ledgers (--dry-run supported).IntegrityVerifier::verifySegment() and CheckpointChainVerifier (fast O(checkpoints) attestation; signature path shared via the VerifiesCheckpointSignature trait).checkpoint_chain_broken, checkpoint_head_mismatch, segment_discontinuous, anchor_invalid.chronicle:verify incremental modes: --checkpoints-only, --from-checkpoint=/--to-checkpoint=, --since-last-checkpoint, --resume.AnchorProvider contract, AnchorReceipt, CheckpointDigest, and AnchorManager (opt-in chronicle.anchoring, enabled defaults false); NullAnchor; and Rfc3161TimestampAnchor (offline openssl ts -verify; adds symfony/process).AnchorCheckpointJob dispatched after the checkpoint commits (anchor failure never rolls a checkpoint back); the shared CheckpointAnchorer writes pending → anchored/failed.chronicle:checkpoint --anchor, chronicle:anchor:retry {--status=failed} (pending/failed), chronicle:anchor:verify {--checkpoint=}, and chronicle:verify --anchors.sequence everywhere (ChainHashEntry, IntegrityVerifier, EntryVerifier, EntryExporter), eliminating false chain_hash_mismatch/chain_invalid when entries share a millisecond across processes.payload, payload_hash, chain_hash are NOT NULL on fresh installs.CheckpointCreator signs a canonical object (id, chain_hash, algorithm, key_id, created_at, mirroring ExportSigner); verification falls back to the legacy bare-hash format, so older checkpoints still verify.chronicle:prune warns that from-genesis verify won't pass post-prune and points to verifyFrom().RateLimitPolicy logs a warning before rejecting an over-limit entry (audit suppression is now observable).ChainHasher::GENESIS; chronicle:install publishes migrations under their dated filenames (idempotent re-runs); checkpoint head resolved by sequence.composer.json and a PendingEntry docblock typo.action, actor_id, metadata, diff, …) and its hash-covered payload — new code column_payload_divergence (shared ComparesEntryColumns trait).$hidden attributes and any $chronicleRedact/$redactedFields entries (records "[redacted]"), so secrets never enter the immutable, exportable audit diff.chronicle:verify --anchors fails at the first anchored checkpoint.php artisan migrate (additive: checkpoint range columns, an index on the existing checkpoint_id entries column, and the two new tables).php artisan chronicle:checkpoints:backfill — populates the range columns + checkpoint_id for pre-1.11 checkpoints (chunked, idempotent; --dry-run to preview). Incremental verify modes fall back to full verify until this runs.chronicle.anchoring.enabled; no behavior change without it.symfony/process (a standard component, not a cloud SDK).No artifact-format change; no re-export needed. See the Upgrade Guide.
Full Changelog: https://github.com/laravel-chronicle/core/compare/1.10.0...1.11.0
This release makes Chronicle's cryptographic trust survive key rotation and key custody outside the application. Signing keys now live in a key ring: one active key signs new artifacts, while every key — active or retired — remains available to verify the artifacts it produced. Because each checkpoint, export, and compliance report records the algorithm and key_id it was signed with, verification resolves the correct historical key automatically.
This release is backward compatible. Existing apps on the 1.9.x flat signing config continue to work with no changes.
chronicle:verify and chronicle:verify-export resolve the signing key from the ring per artifact, so artifacts signed by a now-retired key still verify.laravel-chronicle/kms-aws companion package. Remote signing, local verification.EcdsaSigningProvider), verified locally with OpenSSL — the foundation for KMS/HSM custody.chronicle:key:* commands for generating, listing, and rotating keys.signing config is adapted to a single-key ring automatically.Signing keys are now configured as a ring with one active key:
// config/chronicle.php
'signing' => [
'active' => env('CHRONICLE_ACTIVE_KEY', 'chronicle-key-1'),
'keys' => [
'chronicle-key-1' => [
'provider' => Chronicle\Signing\Ed25519SigningProvider::class,
'algorithm' => 'ed25519',
'private_key' => env('CHRONICLE_PRIVATE_KEY'), // set null once retired
'public_key' => env('CHRONICLE_PUBLIC_KEY'), // keep for verification
],
],
],
Verification now distinguishes between a genuinely invalid signature and an unknown key: if an artifact references a key that is no longer in the ring, verification fails with a new unknown_key reason rather than reporting a forged signature.
php artisan chronicle:key:generate --id=chronicle-key-2 # mint a new keypair
# add the printed entry to signing.keys, then:
php artisan chronicle:key:rotate chronicle-key-2 # anchors a boundary checkpoint
# set CHRONICLE_ACTIVE_KEY=chronicle-key-2 and deploy
chronicle:key:rotate always creates a boundary checkpoint at the current ledger head before handing over, so the transition between keys is itself verifiable. When you later retire a key, keep its public_key in the ring and drop only the private_key.
Signing providers are pluggable, so the private key can live entirely outside the application. Providers sign remotely and verify locally against a cached public key, keeping verification offline and fast.
The official AWS KMS adapter ships as a separate package (core stays free of cloud SDK dependencies):
composer require laravel-chronicle/kms-aws
To build your own (GCP KMS, HashiCorp Vault, HSM, …), implement a signing provider on top of the core LocalVerifyProvider base. See Custom Signing Providers.
| Command | Purpose |
|---|---|
chronicle:key:generate {--id=} |
Generate an Ed25519 keypair and print a ready-to-paste signing.keys entry |
chronicle:key:list {--with-counts} |
List the keys in the ring, marking the active and verify-only keys |
chronicle:key:rotate {keyId} |
Create a boundary checkpoint and print activation instructions for a new key |
1. New extension requirement. ECDSA support adds ext-openssl to the requirements (alongside the existing ext-sodium). Both are bundled with most PHP distributions.
2. Nothing else is required. Your existing flat signing config keeps working unchanged — Chronicle adapts it to a single-key ring at boot. You'll see a one-time deprecation notice in your logs pointing to the new shape.
3. Recommended (optional): migrate to the key-ring config. Move your current key into signing.keys and set signing.active:
'signing' => [
'active' => env('CHRONICLE_ACTIVE_KEY', 'chronicle-dev-key'),
'keys' => [
'chronicle-dev-key' => [
'provider' => Chronicle\Signing\Ed25519SigningProvider::class,
'algorithm' => 'ed25519',
'private_key' => env('CHRONICLE_PRIVATE_KEY'),
'public_key' => env('CHRONICLE_PUBLIC_KEY'),
],
],
],
Re-publish the config to see the fully documented block:
php artisan vendor:publish --tag=chronicle-config --force
There are no database migrations in this release — checkpoints and exports already recorded the algorithm and key_id needed for multi-key verification.
^8.2^12.0 or ^13.0ext-sodium, ext-opensslFull Changelog: https://github.com/laravel-chronicle/core/compare/1.9.1...1.10.0
Full Changelog: https://github.com/laravel-chronicle/core/compare/1.9.0...1.9.1
This release adds an optional web interface for browsing the audit ledger directly from your Laravel application. The UI is disabled by default and has no impact on applications that do not opt in. No breaking changes. No migration required.
Set CHRONICLE_UI_ENABLED=true in your .env (or publish the config and set chronicle.ui.enabled directly). Routes are only registered when the UI is enabled — there is no middleware overhead for applications that leave it off.
CHRONICLE_UI_ENABLED=true
Publish the Blade views if you want to customize them:
php artisan vendor:publish --tag=chronicle-views
GET /chronicle — paginated list of all audit entries.
Filter by any combination of action, actor ID, subject type, subject ID, tag, and date range. Sort ascending or descending by entry ID. Pagination page size is configurable via CHRONICLE_UI_PER_PAGE (default 25).
GET /chronicle/entries/{id} — full view of a single entry.
Shows actor, subject, action, payload, tags, correlation ID, hash chain values (payload hash and chain hash), and the linked checkpoint record when present.
GET /chronicle/stats — aggregate overview of the ledger.
// config/chronicle.php
'ui' => [
'enabled' => env('CHRONICLE_UI_ENABLED', false),
'prefix' => env('CHRONICLE_UI_PREFIX', 'chronicle'),
'middleware' => ['web', 'auth'],
'per_page' => env('CHRONICLE_UI_PER_PAGE', 25),
],
| Key | Env var | Default | Description |
|---|---|---|---|
| ui.enabled | CHRONICLE_UI_ENABLED | false | Registers routes and enables the UI |
| ui.prefix | CHRONICLE_UI_PREFIX | 'chronicle' | URL prefix for all UI routes |
| ui.middleware | — | ['web', 'auth'] | Middleware applied to all UI routes |
| ui.per_page | CHRONICLE_UI_PER_PAGE | 25 | Entry index pagination size |
[!NOTE] ui.middleware defaults to ['web', 'auth'], which means unauthenticated requests are redirected to your application's login route. Replace or extend this array in config/chronicle.php to suit your own authorization model.
| Name | Url |
|---|---|
| chronicle.entries.index | /{prefix} |
| chronicle.entries.show | /{prefix}/entries/{id} |
| chronicle.stats | /{prefix}/stats |
Full Changelog: https://github.com/laravel-chronicle/core/compare/1.8.1...1.9.0
chronicle:install previously delegated migration publishing to vendor:publish, which copied files verbatim — leaving them without a date prefix. Migrations without a timestamp are valid but inconsistent with the rest of a Laravel project's database/migrations directory.
Starting in 1.8.1, the install command stamps each published file at publish time:
database/migrations/ 2026_05_25_120000_create_chronicle_checkpoints_table.php 2026_05_25_120001_create_chronicle_entries_table.php
This matches the behavior of php artisan make:migration and integrates cleanly with migrate:status, rollback ordering, and IDE migration tooling.
Re-running chronicle:install --force republishes with a fresh timestamp. Running without --force is safe — files whose base name already exists in database/migrations are skipped.
The nine incremental migration files (one per column group added across the 1.x lifetime) have been replaced by two clean files:
create_chronicle_checkpoints_tablecreate_chronicle_entries_tableFresh install now run two migrations instead of nine. The schema is identical — this is a packaging improvement only. Existing installations are not affected.
Full Changelog: https://github.com/laravel-chronicle/core/compare/1.8.0...1.8.1
This release ships five quality-of-life improvements across testing, observability, model integration, and querying. No breaking changes. No migration required.
Chronicle::fake() — first-class test supportChronicle now has a built-in fake that works like Mail::fake() or Queue::fake(). Call it at the start of a test to
swap the driver to in-memory storage and get back a fluent assertion helper. Entries do not touch the database while
faking.
it('records an entry when an invoice is sent', function () {
$fake = Chronicle::fake();
sendInvoice($invoice);
$fake->assertRecorded(fn ($e) => $e['action'] === 'invoice.sent');
});
Available assertions:
$fake->assertRecorded(); // at least one entry
$fake->assertRecorded(fn ($e) => $e['action'] === 'x.y'); // with filter
$fake->assertRecordedCount(3); // exact count
$fake->assertRecordedCount(1, fn ($e) => ...); // count with filter
$fake->assertNothingRecorded(); // nothing at all
$fake->assertNotRecorded(fn ($e) => $e['action'] === 'x.y'); // nothing matching
$fake->entries(); // raw Collection
All assertions throw PHPUnit\Framework\AssertionFailedError on failure, so they integrate with Pest's ->expect() and
standard PHPUnit output. Calling fake() twice flushes the first batch — no manual teardown needed.
Chronicle now dispatches Laravel events you can listen to anywhere in your application.
Chronicle\Events\EntryRecorded fires after every successful synchronous commit. The event carries the persisted
Entry
model:
Event::listen(EntryRecorded::class, function (EntryRecorded $event): void {
broadcast(new AuditEntryCreated($event->entry));
});
Chronicle\Events\EntryRejected fires when a validator or policy rejects an entry. The exception is
always re-thrown after the event — your error handling is unchanged:
Event::listen(EntryRejected::class, function (EntryRejected $event): void {
Log::warning('Chronicle entry rejected', [
'reason' => $event->reason->getMessage(),
'action' => $event->payload['action'] ?? null,
]);
});
Register listeners in AppServiceProvider::boot() (Laravel 11+) or EventServiceProvider:
use Chronicle\Events\EntryRecorded;
use Chronicle\Events\EntryRejected;
Event::listen(EntryRecorded::class, SendAuditWebhook::class);
Event::listen(EntryRejected::class, LogRejectedEntry::class);
[!NOTE]
EntryRecordedfires inside the queue worker when using thequeueddriver, not during the HTTP request.EntryRecordedis suppressed whenNullDriveris active.
HasChronicle only works on models you control. ChronicleModelObserver covers the rest — models from
packages, vendor code, or any class you can't modify directly.
// AppServiceProvider::boot()
Chronicle::observe(Payment::class);
Chronicle::observe(Subscription::class);
This records created, updated, and deleted events using the same conventions as HasChronicle:
snake_case(class_basename($model)) — e.g., payment.createdAuth::user() or system when unauthenticatedupdated entries include a diff of changed fields, excluding created_at / updated_atOverride any protected method to tailor the behavior:
class InvoiceObserver extends ChronicleModelObserver
{
protected function actionPrefix(Model $model): string
{
return 'billing.invoice';
}
protected function resolveActor(Model $model): mixed
{
return $model->owner;
}
protected function ignoredFields(Model $model): array
{
return ['updated_at', 'pdf_path', 'cache_key'];
}
protected function recordedEvents(): array
{
return ['created', 'updated']; // skip deleted
}
}
// Register it:
Chronicle::observe(Invoice::class, InvoiceObserver::class);
Filter entries by action namespace without writing raw LIKE queries. The prefix is LIKE-escaped so characters like %
and _ are treated literally.
// All invoice-related entries
Chronicle::query()->actionPrefix('invoice.')->get();
// Chainable with other filters
Chronicle::query()
->actionPrefix('invoice.')
->forSubject($customer)
->since(now()->subDays(30))
->get();
Entry::scopeActionPrefix() is also available for direct Eloquent queries:
Entry::query()->actionPrefix('billing.')->latestFirst()->limit(50)->get();
compute() now accepts optional from and to bounds. All aggregate stats — entry count, oldest/newest
timestamps, top actions, daily activity — are scoped to the window. Checkpoint count remains global.
$stats = LedgerStats::compute(
from: now()->startOfMonth(),
to: now()->endOfMonth(),
);
$stats->totalEntries(); // entries this month only
$stats->topActions(); // top actions this month only
Existing call sites with no arguments are unaffected.
No database migrations required. No configuration changes required.
If you use NullDriver in tests via swapDriver(), note that commit() now short-circuits before the pipeline when NullDriver is active — validation extensions do not run. This matches NullDriver's documented intent (silent discard) and is a no-op for tests that only care whether commit() succeeds.
Full Changelog: https://github.com/laravel-chronicle/core/compare/1.7.0...1.8.0
queued driverThe synchronous write pipeline holds a SELECT ... FOR UPDATE lock on chronicle_entries for every
request. Under a concurrent load this serializes writes and becomes a bottleneck.
v1.7 introduces a queued driver that moves the lock-sensitive stages (ChainHashEntry → PersistEntry) off the HTTP thread into a background job. The HTTP request exits as soon as the pre-pipeline (validation → canonicalize → hash payload) completes and the job is dispatched.
Enable it:
CHRONICLE_DRIVER=queued
CHRONICLE_QUEUE=chronicle # queue name (default: "chronicle")
CHRONICLE_QUEUE_CONNECTION=redis # optional — uses app default if unset
Run the worker:
php artisan queue:work --queue=chronicle --tries=1
[!WARNING]
Single-worker requirement. Chronicle chain hashes are order-sensitive. Running multiple workers on the Chronicle queue will corrupt the audit chain. Use exactly one worker on this queue.
After commit() returns, the entry is not yet in the database — it appears once the worker processes the job. Set queue.default = sync in test environments to keep entries immediately visible.
PersistChronicleEntryJob has $tries = 1 and cannot be retried. A partial job execution would produce a duplicate or broken chain.
CHRONICLE_DRIVER=database is now accepted as an alias for eloquent. Both resolve to the synchronous DatabaseDriver. Existing eloquent configurations are unaffected.
php artisan chronicle:prune --older-than=365 # delete entries > 365 days old
php artisan chronicle:prune --before=2024-01-01 # delete entries before a date
php artisan chronicle:prune --older-than=365 --dry-run # preview without deleting
php artisan chronicle:prune --older-than=365 --force # include checkpoint-anchored entries
By default, the command refuses to delete any entry that is anchored by a checkpoint (checkpoint_id IS NOT NULL). Pass --force to override.
Configure a default retention period so the command can run unattended (e.g., via scheduler):
// config/chronicle.php
'prune' => [
'default_retention_days' => 365, // null = no default, must pass --older-than or --before
'respect_checkpoints' => true,
],
Deletes are batched in chunks of 1000 rows via DB::table() to avoid memory exhaustion on large ledgers.
chronicle.queue and chronicle.prune are added automatically via mergeConfigFrom. Publish the updated config if you want to edit them: php artisan vendor:publish --tag=chronicle-config.Full Changelog: https://github.com/laravel-chronicle/core/compare/1.6.1...1.7.0
Full Changelog: https://github.com/laravel-chronicle/core/compare/1.6.0...1.6.1
All changes in this release are purely additive. Nothing in the write path, pipeline, or hash chain was touched.
Chronicle::query() — fluent ledger query builderA new LedgerQuery builder is now accessible via Chronicle::query(). It wraps the existing Entry
Eloquent scopes behind a chainable, composable API:
Chronicle::query()
->forActor($user)
->withTag('billing')
->since('2026-01-01')
->until('2026-03-31')
->latest()
->paginate(perPage: 25, cursor: $request->cursor);
Filter methods: forActor, forSubject, action, actions, withTag, withTags, withAnyTag, since, until, between, correlation, workflow, latest, oldest
Terminal methods: get(), first(), count(), exists(), paginate(), stream()
withTags(['a', 'b']) — AND semantics (entry must carry all tags)withAnyTag(['a', 'b']) — OR semantics (entry must carry any of the tags)since() / until() accept both CarbonInterface and parseable date strings; an unparseable string throws InvalidArgumentExceptionget() and paginate() unless latest() or oldest() is called explicitlyLedgerStats::compute() — aggregate ledger statistics
$stats = LedgerStats::compute();
$stats->totalEntries(); // int
$stats->oldestEntryAt(); // ?CarbonInterface
$stats->newestEntryAt(); // ?CarbonInterface
$stats->checkpointCount(); // int
$stats->topActions(); // [['action' => string, 'count' => int], ...] — top 10
$stats->dailyActivity(); // [['date' => 'Y-m-d', 'count' => int], ...] — last 30 days
$stats->isEmpty(); // bool
Queries run directly via the configured Chronicle DB connection using the query builder (no Eloquent overhead). Works on SQLite, MySQL, and PostgreSQL.
chronicle:stats — displays a formatted ledger statistics report in the terminal.
Total entries: 12,847
Oldest entry: 2026-01-03 09:14:22 UTC
Newest entry: 2026-05-06 17:33:01 UTC
Checkpoints: 14
Top Actions
-----------
...
Activity (last 30 days)
----------------------
...
Pass --json for machine-readable output:
php artisan chronicle:stats --json
chronicle:show {id} — prints the full detail of a single entry by ULID.
php artisan chronicle:show 01JVXYZ...
Displays actor, action, subject, tags, correlation ID, checkpoint ID, payload/chain hashes, metadata, context (nested keys flattened with dot-notation), and diff (old/new per changed field). Exits 1 with an error message when the entry is not found.
currentCorrelation() was incorrectly declared as currentCorrelationId().Full Changelog: https://github.com/laravel-chronicle/core/compare/1.5.0...1.6.0
HasChronicle — automatic Eloquent audit trailAdd the trait to any Eloquent model and Chronicle starts recording created, updated, and deleted events with no configuration required.
use Chronicle\Eloquent\HasChronicle;
class Order extends Model
{
use HasChronicle;
}
Out of the box:
Customise per model with a handful of properties and overrides:
class Order extends Model
{
use HasChronicle;
// Don't record changes to these fields in the diff
protected array $chronicleIgnore = ['internal_notes'];
// Only record created and deleted — skip updated
protected array $chronicleEvents = ['created', 'deleted'];
// Custom action prefix
protected function chronicleActionPrefix(): string
{
return 'shop.order';
}
// Custom actor (e.g. resolve from a tenant context)
protected function chronicleActor(): mixed
{
return Tenant::current();
}
}
chronicle:report — signed compliance reports
Generate a tamper-evident HTML compliance summary for the audit ledger, signed with your Ed25519 key.
php artisan chronicle:report /var/exports/report-2026-q2.html
php artisan chronicle:report /var/exports/report-2026-q2.html --from=2026-04-01 --to=2026-06-30
The report includes entry count, chain head, reporting period, a SHA-256 report hash, and an Ed25519 signature block. It is a self-contained HTML file — printable to PDF by any browser, no additional dependencies.
The Chronicle\Reports\ComplianceReport service is available for programmatic use if you need to generate reports from your own code.
chronicle:verify --entry= — single-entry spot check
Verify a specific entry without scanning the entire ledger — the go-to command for support workflows.
php artisan chronicle:verify --entry=01JWXYZ...
Verifying entry 01JWXYZ......
Action: order.updated
Subject: App\Models\Order#1042
Actor: App\Models\User#7
Created: 2026-05-01 09:14:33
✓ Payload hash OK
✓ Chain hash OK
Entry integrity verified.
Checks the entry's payload hash (data integrity) and chain hash (position integrity). Exits 0 on success, 1 on any tampering or if the entry is not found.
The underlying Chronicle\Verification\EntryVerifier service is available for programmatic use.
Full Changelog: https://github.com/laravel-chronicle/core/compare/1.4.1...1.5.0
This patch release addresses three security findings, fixes several correctness bugs, and closes API gaps in the LedgerReader contract. One behaviour change requires an upgrade action for some applications — see the note below.
Export verification gap — tampered payload passes verification
ExportVerifier was checking the chain hash but not re-deriving payload_hash from the exported payload field. An attacker with write access to an export directory could modify payload while leaving payload_hash unchanged, and the verification would pass. The verifier now recomputes the payload hash per-entry and fails with payload_hash_mismatch if they diverge.
Private key exposure after signing
Ed25519SigningProvider now calls sodium_memzero() in __destruct() to zero the private key bytes from memory as soon as the provider goes out of scope. Previously the key remained in memory until the PHP process ended.
Sensitive data leakage in audit context
RequestContextResolver now redacts the values of sensitive query parameters (password, token, api_token, secret, key, access_token) from the logged URL, replacing them with [redacted]. User-agent strings are also truncated to 512 characters. Without this, credentials passed as query parameters could be persisted verbatim in context.request.url for every Chronicle entry recorded during that request.
DefaultReferenceResolver previously accepted raw scalar values as actor or subject references and silently stored PHP's gettype() return value ("integer", "string", etc.) as the type — producing malformed entries with no actor or subject identity. It now throws InvalidArgumentException immediately.
Affected code: Any call that passes a scalar directly as actor or subject, for example:
// Before (silently broken)
Chronicle::record()->actor(42)->subject('some-string')->...
// After — pass a model or a plain object with a public $id
Chronicle::record()->actor($user)->subject($order)->...
// or
$ref = new stdClass; $ref->id = '42';
Chronicle::record()->actor($ref)->...
The reserved 'system' string used with ->actor('system') is not affected — it is handled by EntryBuilder before reaching the resolver.
Full Changelog: https://github.com/laravel-chronicle/core/compare/1.4.0...1.4.1
Chronicle v1.4 introduces an opt-in Policy System — a clean way to enforce rules governing which audit entries are allowed to be recorded. Policies run after validation and context resolution, before hashing and persistence. A rejected entry never touches the ledger.
| Policy | What it enforces |
|---|---|
OnlyAuthenticatedUsersPolicy |
Requires an active authenticated session. Skips in console and queue |
| contexts. | |
AllowedActionsPolicy |
Restricts recording to a configured list of allowed action patterns |
(wildcard support via Str::is()). An empty allowlist rejects everything. |
|
ForbiddenActionsPolicy |
Blocks actions matching a configured denylist. An empty denylist passes |
| all. | |
RateLimitPolicy |
Caps entries per actor per time window using Laravel's RateLimiter facade. |
TimeWindowPolicy |
Restricts recording to configured hours and days of the week, with timezone |
| support. | |
ContextPolicy |
Requires specific top-level keys to be present in the entry's context attribute. |
All policy rejections extend PolicyViolationException → ChronicleException, so existing catch (ChronicleException) handlers receive them without changes. Six dedicated subclasses
(UnauthenticatedActorException, ActionNotAllowedException, ActionForbiddenException,
RateLimitExceededException, OutsideTimeWindowException, RequiredContextMissingException) allow
fine-grained handling.
All policies are opt-in. Uncomment in config/chronicle.php:
'extensions' => [
// Optional policies — uncomment to enable:
\Chronicle\Policy\OnlyAuthenticatedUsersPolicy::class,
\Chronicle\Policy\AllowedActionsPolicy::class,
\Chronicle\Policy\ForbiddenActionsPolicy::class,
\Chronicle\Policy\RateLimitPolicy::class,
\Chronicle\Policy\TimeWindowPolicy::class,
\Chronicle\Policy\ContextPolicy::class,
],
Custom policies
Extend AbstractPolicy and implement enforce(PendingEntry $entry): void. Throw a PolicyViolationException to reject, return silently to allow. Constructor injection is the extension point for services.
Full documentation: Policies guide
v1.3 introduces the Context Resolvers system — an opt-in extension layer that automatically enriches audit entries with namespaced runtime context before they are hashed and persisted.
Context resolvers run in the RESOLVE_CONTEXT extension stage and write structured data into the
entry's context attribute under an isolated key. Chronicle ships five built-in resolvers, all
disabled by default.
| Resolver | Context key | What it attaches |
|---|---|---|
EnvironmentContextResolver |
environment |
App env name, debug flag |
RequestContextResolver |
request |
IP, user agent, URL, method, request ID |
HostContextResolver |
host |
Server hostname |
ProcessContextResolver |
process |
PID, runtime, PHP version |
QueueContextResolver |
queue |
Job ID, connection, queue name |
Enabling any resolver is a one-line config change:
'extensions' => [
// existing validators...
\Chronicle\Context\EnvironmentContextResolver::class,
\Chronicle\Context\RequestContextResolver::class,
\Chronicle\Context\HostContextResolver::class,
\Chronicle\Context\ProcessContextResolver::class,
\Chronicle\Context\QueueContextResolver::class,
],
Notable behaviour
Custom resolvers
Extend AbstractContextResolver, implement contextKey() and resolve(), and register it like any other extension:
final class TenantContextResolver extends AbstractContextResolver
{
public function contextKey(): string { return 'tenant'; }
public function resolve(PendingEntry $entry): ?array
{
$tenant = tenant();
return $tenant ? ['id' => $tenant->id, 'slug' => $tenant->slug] : null;
}
}
Return null to skip the resolver silently for a given entry.
See the Context Resolvers documentation for the full guide.
Full changelog
https://github.com/laravel-chronicle/core/blob/main/CHANGELOG.md#130---2026-03-19
Chronicle 1.2 ships a complete built-in validation layer. Nine validators run as VALIDATE-stage
extensions before any entry is hashed, chained, or persisted. Each validator is individually removable
from chronicle.extensions if your application needs to relax a constraint.
| Validator | What it enforces |
|---|---|
ActorPresenceValidator |
actor_type and actor_id are non-blank strings |
SubjectValidator |
Subject fields are present (waived for actor_type=system) |
ActionValidator |
Action is a string, uses dot notation, within configured max length |
CorrelationValidator |
correlation_id, when set, is a non-blank string within configured max |
| length | |
TagLimitValidator |
Tag count does not exceed chronicle.validation.tag_limit (default 10) |
TagsValidator |
Each tag is a non-empty, unique string within configured max length |
DiffStructureValidator |
Diff has {key: {old: X, new: Y}} shape; values are serializable |
PayloadSerializableValidator |
metadata, context, diff contain no closures, resources, or |
| objects | |
PayloadSizeValidator |
Combined serialized payload fits within |
chronicle.validation.max_payload_size (default 64 KB) |
'validation' => [
'action_max_length' => env('CHRONICLE_ACTION_MAX_LENGTH', 255),
'tag_max_length' => env('CHRONICLE_TAG_MAX_LENGTH', 50),
'tag_limit' => env('CHRONICLE_TAG_LIMIT', 10),
'correlation_id_max_length' => env('CHRONICLE_CORRELATION_ID_MAX_LENGTH', 255),
'max_payload_size' => env('CHRONICLE_MAX_PAYLOAD_SIZE', 65536),
],
New exception classes
- InvalidActionException
- InvalidTagsException
- InvalidCorrelationIdException
- InvalidDiffException
- InvalidPayloadSizeException
- UnserializablePayloadException
Other improvements
- EloquentLedgerReader exposes workflow(), withTag(), and withTags() scopes through the LedgerReader
contract.
- ExportVerificationResult now uses accessor methods (failureCode(), entryCount(), datasetHash(),
chainHead()) instead of public properties.
- ArrayDriver now fully mirrors EloquentDriver behaviour — all JSON columns are encoded and all Entry
fields are populated.
- IntegrityVerifier caches verified checkpoints in memory, reducing checkpoint queries from one per
entry to one per unique checkpoint.
- ChronicleManager::transaction() pushes correlation IDs consistently for both callback-style and
manual-style transactions.
Breaking changes
- ExportVerificationResult public properties (failure, entryCount, datasetHash, chainHead) have been
replaced by accessor methods. Update any code that reads these properties directly.
Full Changelog: https://github.com/laravel-chronicle/core/compare/1.0.2...1.1.0
Full Changelog: https://github.com/laravel-chronicle/core/compare/1.0.1...1.0.2
Full Changelog: https://github.com/laravel-chronicle/core/compare/1.0.0...1.0.1
Chronicle v1.0.0 marks the first stable release of the Chronicle audit ledger engine for Laravel.
Chronicle provides a cryptographically verifiable, append-only audit log designed for systems that require strong integrity guarantees, traceability, and operational transparency.
This release finalizes Chronicle’s core architecture, data model, and export format, establishing a stable foundation for long-term use.
Chronicle is an append-only audit logging engine for Laravel applications.
It records system events as immutable ledger entries and protects them with cryptographic integrity guarantees.
Chronicle is designed for:
Unlike traditional activity logs, Chronicle entries cannot be modified or deleted once recorded.
Chronicle is built around a small set of strict design principles:
Entries are immutable from the moment they are recorded.
No update or delete operations exist in the core API.
Every entry must explicitly declare:
Chronicle never records anonymous events.
Chronicle protects the ledger using:
Any modification, deletion, or reordering of entries is detectable.
The following structures are now considered stable:
Future versions may extend these structures but will not break them.
Chronicle works equally well in:
Chronicle does not assume a request lifecycle.
Chronicle ensures ledger integrity using a hash chain.
Each entry includes a chain hash computed from the previous entry:
chain_hash(n) = SHA256(chain_hash(n-1) + payload_hash(n))
This guarantees that:
without detection.
Chronicle supports cryptographic checkpoints that periodically anchor the ledger.
A checkpoint records:
This allows auditors to verify ledger integrity even if the database itself is compromised.
Chronicle can export the ledger as a portable verification dataset.
Exports include:
entries.ndjson
manifest.json
signature.json
Export datasets can be independently verified using:
This allows Chronicle audit logs to be validated outside the originating system.
Chronicle provides a fluent query API for retrieving entries.
Common queries include:
Entry::forActor($user);
Entry::forSubject($order);
Entry::action('invoice.updated');
Entry::withTag('security');
These scopes simplify common audit queries while remaining efficient for large ledgers.
Chronicle is designed to scale efficiently for large datasets.
Key features include:
Efficient traversal of large ledgers without offset queries.
Ledger entries can be streamed using database cursors:
Entry::stream()->each(function ($entry) {
// process entry
});
Streaming allows processing millions of entries with constant memory usage.
Chronicle includes optimized indexes for common query patterns such as:
Chronicle introduces a LedgerReader abstraction that provides a stable read API.
This allows UI packages and external tooling to access the ledger without coupling to the underlying database model.
Example:
Chronicle::reader()->paginate();
Chronicle::reader()->stream();
Chronicle::reader()->forSubject($invoice);
Chronicle includes tools for verifying exported datasets.
Verification ensures:
This provides strong guarantees that exported audit logs have not been tampered with.
Chronicle v1.0.0 includes formal documentation for its public contracts:
DATA_MODEL.mdEXPORT_FORMAT.mdThese documents define the ledger structure and export format for external integrations and auditors.
Chronicle 1.x will focus on extending the ecosystem while preserving core stability.
Planned additions include:
These features will be implemented as optional extensions to maintain the stability of the core ledger engine.
Chronicle v1.0.0 represents the completion of the initial design and implementation of a verifiable audit ledger for Laravel.
The project will continue evolving with a strong emphasis on stability, integrity, and transparency.
We hope Chronicle becomes a reliable foundation for audit logging in Laravel applications.
How can I help you explore Laravel packages today?