Weave Code
Code Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Core Laravel Package

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.

View on GitHub
Deep Wiki
Context7
1.13.0

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.

What's new

Config-resolvable entry model

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.

Entry-bounded range verification

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.

Reverse reference resolution

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.

Verification-preserving test seeding

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.

Upgrading

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.

Compatibility

  • PHP ^8.2
  • Laravel ^12.0 or ^13.0
  • ext-sodium, ext-openssl

Quality

898 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

1.12.1

What's Changed

Full Changelog: https://github.com/laravel-chronicle/core/compare/1.12.0...1.12.1

1.12.0

Chronicle v1.12.0 — GDPR Erasure via Crypto-Shredding

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.


✨ Highlights

  • 🔐 Crypto-shredding — per-subject payload encryption (XChaCha20-Poly1305-IETF) with the entry envelope bound in as associated data. Encrypting happens before hashing, so erasure never breaks the chain.
  • 🗑️ GDPR erasurechronicle:subject:erase destroys a subject's key and records a verifiable, PII-free subject.erased proof you can show a regulator.
  • Erase-and-still-verify — after erasure, chronicle:verify still passes; reads of erased fields return a tombstone, while the cleartext envelope (actor, action, subject, timestamp, tags) stays queryable.
  • 🔑 Pluggable key custody — per-subject DEKs are wrapped by a KEK. The default KEK is local; keep it in a KMS via laravel-chronicle/kms-aws so it never lives in the app.
  • ⚖️ Legal hold — block erasure and pruning of subjects under litigation hold.
  • ♻️ KEK rotation — re-wrap every DEK under a new KEK without touching ciphertext or hashes.
  • ↩️ Backward compatible — encryption is off by default; mixed cleartext/encrypted ledgers verify and export normally.

🔐 How it works

// 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.


🗑️ Erasing a subject

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.


🆕 New Artisan commands

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)

⚖️ Legal hold & key rotation

  • 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.

⬆️ Upgrade guide

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.


⚠️ Important notes

  • Erasure scope. Crypto-shredding guarantees erasure in the live store. Backups taken before an erasure still contain recoverable data and are governed by your backup-retention policy.
  • Not legal advice. Whether retaining a pseudonymised event record satisfies a particular erasure request is a determination for your DPO / legal counsel.
  • Keep CHRONICLE_ENCRYPTION_KEY safe and separate. Losing the KEK makes all wrapped DEKs (and therefore all encrypted content) unrecoverable; that is by design.

📚 Documentation


Requirements

  • PHP ^8.2
  • Laravel ^12.0 or ^13.0
  • ext-sodium, ext-openssl

Full Changelog: https://github.com/laravel-chronicle/core/compare/1.11.0...1.12.0

1.11.0

v1.11.0 — Scalable Verification & External Anchoring

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.

Highlights

  • External anchoring (opt-in). Copy a per-checkpoint digest — 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.
  • Scalable verification. New 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.
  • Range-aware checkpoints. Checkpoints now record 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.
  • Concurrency-safe ordering. A monotonic 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.
  • Companion package: the new laravel-chronicle/anchor-s3 reference adapter anchors to an S3 Object Lock (WORM) bucket.

Added

  • Monotonic 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.
  • Range-aware checkpoints: 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).
  • New failure reasons: checkpoint_chain_broken, checkpoint_head_mismatch, segment_discontinuous, anchor_invalid.
  • chronicle:verify incremental modes: --checkpoints-only, --from-checkpoint=/--to-checkpoint=, --since-last-checkpoint, --resume.
  • External anchoring: the AnchorProvider contract, AnchorReceipt, CheckpointDigest, and AnchorManager (opt-in chronicle.anchoring, enabled defaults false); NullAnchor; and Rfc3161TimestampAnchor (offline openssl ts -verify; adds symfony/process).
  • Anchoring pipeline: a queued, retryable AnchorCheckpointJob dispatched after the checkpoint commits (anchor failure never rolls a checkpoint back); the shared CheckpointAnchorer writes pending → anchored/failed.
  • Anchor commands: chronicle:checkpoint --anchor, chronicle:anchor:retry {--status=failed} (pending/failed), chronicle:anchor:verify {--checkpoint=}, and chronicle:verify --anchors.
  • Ledger order is derived from 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).
  • Genesis seed unified on ChainHasher::GENESIS; chronicle:install publishes migrations under their dated filenames (idempotent re-runs); checkpoint head resolved by sequence.

Fixed

  • Export ordering matches chain/verification ordering — fixes export-verification false failures under clock skew.
  • Corrected author email in composer.json and a PendingEntry docblock typo.

Security

  • Verification now detects divergence between an entry's denormalized columns (action, actor_id, metadata, diff, …) and its hash-covered payload — new code column_payload_divergence (shared ComparesEntryColumns trait).
  • Model diffs redact $hidden attributes and any $chronicleRedact/$redactedFields entries (records "[redacted]"), so secrets never enter the immutable, exportable audit diff.
  • External anchoring defeats a full internal compromise: even if an attacker rewrites the ledger and re-signs every checkpoint with a valid key (offline verify passes), chronicle:verify --anchors fails at the first anchored checkpoint.

Upgrading from 1.10

  1. php artisan migrate (additive: checkpoint range columns, an index on the existing checkpoint_id entries column, and the two new tables).
  2. 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.
  3. Anchoring is opt-in via chronicle.anchoring.enabled; no behavior change without it.
  4. New runtime dependency: 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

1.10.0

Chronicle v1.10.0 — Key Rotation, Multi-Key Verification & External Signing

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.


✨ Highlights

  • 🔑 Signing-key rotation with a multi-key key ring — rotate keys without invalidating historical checkpoints or exports.
  • 🔎 Multi-key verificationchronicle:verify and chronicle:verify-export resolve the signing key from the ring per artifact, so artifacts signed by a now-retired key still verify.
  • 🧩 External signing providers — sign with keys held outside the app (e.g. AWS KMS) via the new laravel-chronicle/kms-aws companion package. Remote signing, local verification.
  • 🆕 ECDSA P-256 provider in core (EcdsaSigningProvider), verified locally with OpenSSL — the foundation for KMS/HSM custody.
  • 🛠️ New chronicle:key:* commands for generating, listing, and rotating keys.
  • ↩️ Fully backward compatible — the legacy flat signing config is adapted to a single-key ring automatically.

🔐 Key rotation & multi-key verification

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.

Rotating a key

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.


🧩 External signing providers (KMS / HSM)

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.


🆕 New Artisan commands

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

⬆️ Upgrade guide

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.


📚 Documentation


Requirements

  • PHP ^8.2
  • Laravel ^12.0 or ^13.0
  • ext-sodium, ext-openssl

New Contributors


Full Changelog: https://github.com/laravel-chronicle/core/compare/1.9.1...1.10.0

1.9.1

What's Changed

Added

  • VerificationFailure enum centralises all verification failure code strings. Static analysis can now catch typos in failure code comparisons.

Changed

  • Breaking: LedgerQuery::paginate() renamed to LedgerQuery::cursorPaginate(). Update any call to Chronicle::query()->paginate() → Chronicle::query()->cursorPaginate().
  • Breaking: chronicle.prune.default_retention_days now defaults to null (was 365). Running chronicle:prune with no arguments no longer silently deletes entries older than one year — an explicit retention policy must be configured. Set CHRONICLE_RETENTION_DAYS=365 to restore the previous behaviour.
  • Breaking: The export signature now covers a canonical JSON payload containing all manifest fields instead of dataset_hash alone. Existing signature.json files produced by previous versions will fail verification — re-export to regenerate.
  • Chronicle UI default middleware changed from ['web', 'auth'] to ['web', 'auth', 'can:view-chronicle']. The gate must be defined in your application. Set chronicle.ui.middleware back to ['web', 'auth'] to restore the previous permissive default.
  • IntegrityVerifier::verify() $onProgress callback signature changes from callable(int $processed, int $total) to callable(int $processed) — the pre-flight COUNT(*) query has been removed.
  • ChronicleUiController middleware is now declared on the route group in routes/ui.php instead of the constructor ($this->middleware() was deprecated in Laravel 11).
  • ChronicleUiController::stats() now delegates entirely to LedgerStats::compute(), eliminating duplicated query logic.
  • chronicle:install no longer calls exec() to open a browser tab — the repo URL is printed to the console instead.
  • HasChronicle now declares $chronicleEvents and $chronicleIgnore as trait properties with defaults, removing property_exists() duck-typing. Ignored-field detection now uses static::CREATED_AT / static::UPDATED_AT.
  • Diff-building logic extracted to ModelDiffBuilder::build() and shared by HasChronicle and ChronicleModelObserver, eliminating drift-prone duplicate implementations.
  • ChronicleModelObserver now exposes protected array $ignoredFields = [] for subclasses to add fields beyond the default ['created_at', 'updated_at'].
  • Policy classes (AllowedActionsPolicy, ForbiddenActionsPolicy, RateLimitPolicy, ContextPolicy) now read config values once in the constructor rather than on every enforce() call.
  • Export file names (entries.ndjson, manifest.json, signature.json) are now defined as constants in ExportFormat.
  • RequestContextResolver now receives Illuminate\Http\Request via constructor injection instead of pulling from the global container.
  • PruneCommand consolidates three identical query constructions into a single buildPruneQuery() helper.
  • ExportVerifier::decodeJsonFile() renamed to tryDecodeJsonFile() to make the "string return means failure code" contract explicit.
  • src/README.md removed.
  • Deleted ChronicleServiceProvider::assertSigningConfiguration() — no callers; enforcement is already in registerSigning().

Deprecated

  • EntryBuilder::modelChanges() now emits a E_USER_DEPRECATED notice and is marked for removal in v2.0. Use modelDiff() instead.

Fixed

  • PersistChronicleEntryJob was reading chronicle.database.connection (non-existent key) instead of chronicle.connection. On apps with a dedicated Chronicle DB connection, the job silently wrote to the wrong database, corrupting the chain.
  • Ed25519SigningProvider::__destruct() called sodium_memzero() without a null guard — if construction threw before assigning the key, the destructor produced a fatal TypeError at GC time.
  • Chronicle::fake() leaked ArrayDriver across tests in the same process. ChronicleAssertions::restore() is now available to clear the binding; ChronicleManager::resetDriver() is exposed as @internal for the same purpose.
  • enforce_on_boot = false now correctly allows the app to boot without signing keys. A NullSigningProvider wraps the original exception so the root cause is preserved.
  • ExportManager no longer re-hashes the export file after writing. The dataset hash is computed inline during the write pass by EntryExporter, closing a TOCTOU window.
  • Export directory is now created with mode 0700 (owner-only) instead of 0755.
  • All hash equality checks in the verification layer now use hash_equals() to prevent timing side-channel attacks.
  • ChainHashEntry now asserts it is running inside an open database transaction, throwing LogicException if not — preventing silent chain-fork bugs.
  • Chain hash creation and verification now order by id only (ULID). The previous created_at + id ordering could select a different predecessor when two rows shared an identical timestamp, producing false chain_hash_mismatch errors.
  • LedgerQuery::stream() and LedgerQuery::first() now apply the same default ORDER BY id ASC as get() and cursorPaginate().
  • chronicle:prune --before= now prints a human-readable error and exits non-zero instead of throwing an uncaught stack trace.
  • chronicle:install now honours --migrate and --no-interaction flags in non-TTY environments.
  • publishMigrations() now uses a fixed base date (2026-01-01) for migration timestamps, making repeated installations produce deterministic file names.
  • ChronicleServiceProvider now validates the configured signing provider implements SigningProvider before instantiation, preventing arbitrary class construction from .env values.
  • ChronicleUiController::show() validates the $id parameter as a ULID before use, returning HTTP 404 for invalid values.
  • CanonicalPayloadSerializer::normalize() now handles Stringable objects, backed enums (cast to value), and unit enums (cast to name). Non-serialisable objects throw UnexpectedValueException.
  • VerifyEntryCommand no longer uses assert() (disabled in production) to guard against a null entry.
  • CheckpointCreator now uses a strict === null check for the chain hash — a corrupt row with chain_hash = '0' no longer falsely triggers the "ledger is empty" error.
  • ExportVerifier now skips blank lines consistently for both dataset hash and chain verification, fixing false dataset_hash_mismatch failures on exports with a trailing newline.
  • ChronicleAssertions now calls $this->driver->allEntries() instead of ArrayDriver::all() (static), making the constructor parameter functional for test isolation.
  • LedgerStats::compute() — dailyActivity() now honours the full requested range when a $from bound is supplied (previously applied a hardcoded 30-day lower bound).
  • LedgerStats::compute() — checkpointCount() now respects $from/$to bounds (previously always returned the total count across all time).
  • RequestContextResolver now redacts sensitive parameters from URL fragments in addition to query strings, preventing OAuth/OIDC tokens from appearing in the audit log.
  • RateLimitPolicy now uses an atomic increment-then-check pattern to prevent concurrent requests from briefly exceeding the configured rate limit.
  • SerializesEntryAttributes now stores a null diff as SQL NULL instead of the JSON string "null", making WHERE diff IS NULL queries work correctly.
  • DefaultReferenceResolver now throws a clear InvalidArgumentException for unsaved Eloquent models instead of silently producing a reference with a null ID.
  • TagsValidator now rejects tags containing non-printable or non-ASCII characters, preventing Unicode homoglyph attacks from bypassing tag-uniqueness checks.
  • DatabaseDriver and ChainHashEntry now correctly fall back to the default DB connection when chronicle.connection is missing or empty.
  • Stat controller null-dereference on ->count when dailyActivity has no entry for a given day — changed to ?->count ?? 0.
  • CanonicalPayloadSerializer::isAssoc() now correctly classifies empty arrays as sequential, matching json_encode behaviour.
  • ComplianceReport::generate() no longer constructs ComplianceReportResult twice; removed @ error-suppression from file write.
  • PayloadSerializableValidator now uses JSON_THROW_ON_ERROR per project convention.
  • ComplianceReport::collectStats() removed misleading @var string annotations on nullable locals.

Full Changelog: https://github.com/laravel-chronicle/core/compare/1.9.0...1.9.1

1.9.0

What's Changed

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.


Enabling the UI

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

Entry index

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).


Entry detail

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.


Stats

GET /chronicle/stats — aggregate overview of the ledger.

  • Total entry count, oldest and newest entry timestamps
  • Total checkpoint count
  • Top 10 actions by frequency
  • 30-day daily activity chart

Configuration

// 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.


Named routes

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

1.8.1

What's Changed

Migration publishing now uses a current timestamp

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.


Migrations consolidated into two files

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_table
  • create_chronicle_entries_table

Fresh 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

1.8.0

v1.8.0 — Developer Experience & Observability

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 support

Chronicle 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.


EntryRecorded and EntryRejected events

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]
EntryRecorded fires inside the queue worker when using the queued driver, not during the HTTP request. EntryRecorded is suppressed when NullDriver is active.


ChronicleModelObserver — audit third-party models

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:

  • action prefix defaults to snake_case(class_basename($model)) — e.g., payment.created
  • actor defaults to Auth::user() or system when unauthenticated
  • updated entries include a diff of changed fields, excluding created_at / updated_at
  • touch-only updates (only timestamp fields dirty) are silently skipped

Custom observer:

Override 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);

LedgerQuery::actionPrefix() — namespace filtering

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();

LedgerStats::compute() — date scoping

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.


Upgrade notes

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

1.7.0

What's changed

Async write path — queued driver

The 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 (ChainHashEntryPersistEntry) 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.


Database driver alias

CHRONICLE_DRIVER=database is now accepted as an alias for eloquent. Both resolve to the synchronous DatabaseDriver. Existing eloquent configurations are unaffected.


chronicle:prune — data retention command

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.


Upgrade notes

  • No migrations required.
  • No breaking changes. The default driver remains eloquent; sync behavior is unchanged.
  • If you are on eloquent and want to stay synchronous, no action is needed.
  • New config keys 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.

New Contributors

Full Changelog: https://github.com/laravel-chronicle/core/compare/1.6.1...1.7.0

1.6.0

What's new in v1.6.0 — Query & Observability

All changes in this release are purely additive. Nothing in the write path, pipeline, or hash chain was touched.

Chronicle::query() — fluent ledger query builder

A 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 InvalidArgumentException
  • Defaults to ledger order (oldest-first) on get() and paginate() unless latest() or oldest() is called explicitly

LedgerStats::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.

New Artisan commands

chronicle:stats — displays a formatted ledger statistics report in the terminal.

Chronicle Ledger Stats

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.

Fixed

  • Chronicle facade @method annotation for currentCorrelation() was incorrectly declared as currentCorrelationId().

Full Changelog: https://github.com/laravel-chronicle/core/compare/1.5.0...1.6.0

1.5.0

What's new in v1.5

HasChronicle — automatic Eloquent audit trail

Add 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:

  • Actor — the authenticated user, or system when unauthenticated
  • Action — derived from the class name: Order → order.created, order.updated, order.deleted
  • Diff — updated entries include a field-level diff (old / new values)
  • touch() guard — timestamp-only updates are ignored; no noise entry is written

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

1.4.1

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.

Security

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.

Upgrade note — scalar actor/subject references now throw

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.

Fixed

  • Entry::scopeWorkflow() — the LIKE query now includes an explicit ESCAPE '!' clause. Without it, PostgreSQL uses a different default escape character than MySQL, causing % and _ characters in correlation IDs to produce incorrect results on Postgres.
  • EntryExporter — metadata and context were missing from the exported NDJSON fields. Exports generated before this release will not include those fields; re-export to get complete records.
  • EntryBuilder::change() — ksort() is now called after each field is assigned, so diff keys are always in alphabetical order regardless of the order change() was called. Previously, a diff built incrementally with change() could have a different key order than one built with diff(), producing a different payload_hash for logically identical entries.
  • TimeWindowPolicy — Carbon time bounds are now parsed once at construction and stored as properties. The previous implementation re-parsed them on every enforce() call, which could theoretically return null and fatal if Carbon's locale/timezone state changed between calls.
  • ArrayDriver::store() — the returned Entry model now has $model->exists = true, consistent with DatabaseDriver. Previously, code checking $entry->exists after a fake Chronicle call would incorrectly see false.
  • ChainHashEntry — added lockForUpdate() to the chain-head query to prevent duplicate chain hash records under concurrent writes.

Added

  • LedgerReader contract now declares workflow(), withTag(), and withTags(). These methods were already implemented in EloquentLedgerReader but absent from the interface, so callers typed against the contract could not call them.
  • DriverResolver::has(string $driver): bool — check whether a named driver has already been registered before calling extend(). Third-party service providers can use this to avoid the duplicate-registration exception when their provider is loaded more than once.

Internal

  • CanonicalizePayload pipeline stage no longer serializes the payload to JSON and immediately decodes it back to an array. It now calls CanonicalPayloadSerializer::normalize() directly. normalize() is now public.
  • SerializesEntryAttributes trait extracted from ArrayDriver, DatabaseDriver, and NullDriver. All three drivers shared identical JSON-encoding logic; they now use a single implementation that also applies JSON_THROW_ON_ERROR consistently.
  • EntryBuilder::normalizeDiff() now throws InvalidArgumentException on a malformed diff entry instead of silently coercing it to ['old' => null, 'new' => null].

Full Changelog: https://github.com/laravel-chronicle/core/compare/1.4.0...1.4.1

1.4.0

What's new

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.

Six built-in policies

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.

Exception hierarchy

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.

Enabling policies

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

1.3.0

What's new

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

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

  • RequestContextResolver skips silently in console and queue contexts. When no X-Request-ID header is present, it generates a UUID and stores it in request attributes — all Chronicle entries within the same HTTP request share the same generated ID.
  • QueueContextResolver skips silently when no queue job is active. The QueueJobContext singleton is populated automatically by event listeners wired in ChronicleServiceProvider — no changes to your application jobs are required.
  • All resolved context is written before hashing — it is tamper-evident and part of the integrity chain.

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

1.2.0

Validation System

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.

New validators

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)

New configuration

'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.
1.1.0

What's Changed

New Contributors

Full Changelog: https://github.com/laravel-chronicle/core/compare/1.0.2...1.1.0

1.0.2

What's Changed

Full Changelog: https://github.com/laravel-chronicle/core/compare/1.0.1...1.0.2

1.0.1

What's Changed

Full Changelog: https://github.com/laravel-chronicle/core/compare/1.0.0...1.0.1

1.0.0

Chronicle v1.0.0 — Stable Release

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.


What is Chronicle?

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:

  • security auditing
  • compliance logging
  • operational observability
  • forensic investigation
  • regulatory reporting

Unlike traditional activity logs, Chronicle entries cannot be modified or deleted once recorded.


Core Principles

Chronicle is built around a small set of strict design principles:

Append-only

Entries are immutable from the moment they are recorded.

No update or delete operations exist in the core API.


Explicit intent

Every entry must explicitly declare:

  • actor
  • action
  • subject

Chronicle never records anonymous events.


Cryptographic integrity

Chronicle protects the ledger using:

  • canonical payload hashing
  • hash chaining
  • signed checkpoints
  • signed export datasets

Any modification, deletion, or reordering of entries is detectable.


Stable contracts

The following structures are now considered stable:

  • entry schema
  • hash chaining mechanism
  • checkpoint model
  • export dataset format

Future versions may extend these structures but will not break them.


Transport agnostic

Chronicle works equally well in:

  • HTTP requests
  • queue workers
  • CLI commands
  • scheduled jobs
  • background processes

Chronicle does not assume a request lifecycle.


Ledger Integrity

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:

  • entries cannot be modified
  • entries cannot be deleted
  • entries cannot be reordered

without detection.


Checkpoints

Chronicle supports cryptographic checkpoints that periodically anchor the ledger.

A checkpoint records:

  • the current chain head
  • entry count
  • timestamp
  • cryptographic signature

This allows auditors to verify ledger integrity even if the database itself is compromised.


Verifiable Dataset Exports

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:

  • dataset hashing
  • digital signatures
  • hash chain validation
  • dataset boundary checks

This allows Chronicle audit logs to be validated outside the originating system.


Query API

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.


Performance

Chronicle is designed to scale efficiently for large datasets.

Key features include:

Cursor pagination

Efficient traversal of large ledgers without offset queries.

Streaming 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.

Indexed queries

Chronicle includes optimized indexes for common query patterns such as:

  • actor history
  • subject timelines
  • correlation workflows

Ledger Reader

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);

Export Verification

Chronicle includes tools for verifying exported datasets.

Verification ensures:

  • dataset integrity
  • signature authenticity
  • hash chain validity
  • dataset boundary correctness

This provides strong guarantees that exported audit logs have not been tampered with.


Documentation

Chronicle v1.0.0 includes formal documentation for its public contracts:

  • DATA_MODEL.md
  • EXPORT_FORMAT.md

These documents define the ledger structure and export format for external integrations and auditors.


Future Development

Chronicle 1.x will focus on extending the ecosystem while preserving core stability.

Planned additions include:

  • validation pipeline
  • context resolvers
  • policy enforcement
  • UI packages (Blade, Filament, Nova)
  • Chronicle Cloud integrations

These features will be implemented as optional extensions to maintain the stability of the core ledger engine.


Thank You

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
terminal42/code-quality-tools
codifyo/ts-generator-bundle
andydefer/laravel-cluster
testo/fiber
mintobit/jobqueue
a4sex/maintenance-bundle
a4sex/entity-date-update
a4sex/client-identifier
a4sex/base-utilites
a4sex/key-value-storage
a4sex/micro-status
chilldev/dependency-injection-extra
datinglibre/datinglibre-app-api
biberltd/corebundle
bricre/symfony-bundle-test
biberltd/logbundle
dominium/http-adapter-bundle
dominium/google-analytics
a4sex/auto-clean-entity
christhompsontldr/laravel-inky