Weave 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

Cryptographically verifiable audit ledger for Laravel. Append-only entries are hash-chained so tampering, deletion, or reordering is detectable—built for security logging, compliance, and forensic audit trails.

View on GitHub
Deep Wiki
Context7

Getting Started

Install the package and run the installer:

composer require laravel-chronicle/core
php artisan chronicle:install

The installer publishes config/chronicle.php and the migrations, and offers to run them. Confirm the hard requirements up front — this is not a pure-PHP package:

php -m | grep -E 'sodium|openssl'

PHP ^8.2, Laravel ^12/^13, ext-sodium (Ed25519) and ext-openssl (ECDSA P-256) are mandatory. Your CI image and any queue worker containers need those extensions too, or tests will fail on the first checkpoint/signing call.

Where to look first, in order: config/chronicle.php (signing ring, references, anchoring), the Chronicle facade, and Chronicle\Entry\Entry. Set up your signing key before anything else — the ledger records fine without it, but checkpoints, exports and compliance reports all sign, and a missing/mis-typed key surfaces at signing time rather than at boot. Generate the keypair, put CHRONICLE_PRIVATE_KEY / CHRONICLE_PUBLIC_KEY in your secret store, and never leave the private key in a committed .env.

First use case: pick one high-value domain event — order.created, user.role_changed, payment.refunded — and record it from the service layer that performs the write:

Chronicle::record()
    ->actor($user)
    ->action('order.created')
    ->subject($order)
    ->metadata(['total' => 1000, 'currency' => 'USD'])
    ->tags(['orders', 'billing'])
    ->commit();

Then, in the same PR, schedule php artisan chronicle:checkpoint in routes/console.php — a ledger with no checkpoints gives you tamper detection on the entries themselves but no signed anchor to prove the head to a third party.

Implementation Patterns

Record at the domain boundary, not the controller. The actor/action/subject triple is the audit contract; put it in the service or an event listener so every entry path (HTTP, queue, CLI, admin action) produces the same shape. metadata should be small and stable — it's part of the hashed payload, so treat its schema as an API you own.

Automatic model auditing is the fast path for the models you control:

class Invoice extends Model
{
    use Chronicle\Eloquent\HasChronicle;
}

created/updated/deleted are recorded with a structured diff. For vendor models or models you don't want to touch, Chronicle::observe(Invoice::class) gives the same coverage. In practice you end up with both: the trait for CRUD-level traceability, explicit record() calls for meaningful business events that deserve a named action and tags.

Querying. The scopes are indexed and compose naturally:

Entry::forSubject($order)->action('order.refunded')->latest()->get();
Entry::withTag('billing')->cursorPaginateLedger(50);
Entry::stream()->each(fn ($entry) => $exporter->row($entry)); // constant memory

For any admin UI, prefer resolveReference() / referenceLabel() — they honour the morph map and hit no database — and only opt into referenceModel()/hydrate: true for the rows you actually render. That distinction is the difference between a timeline page that loads in 40ms and one that fires two queries per row.

Operational cadence. A sensible production shape:

  • chronicle:checkpoint every 5–60 minutes (and after any bulk import).
  • chronicle:verify --checkpoints-only --anchors on a schedule — O(checkpoints), cheap enough to run hourly — as a canary.
  • chronicle:verify full, nightly/off-peak on small ledgers; --since-last-checkpoint or --resume on large ones.
  • chronicle:anchor:retry if anchoring is enabled, so transient TSA failures don't pile up.

Wire the exit codes into your monitoring; a failing verify is a security incident, not a warning.

Key rotation runbook — rehearse this before you need it:

php artisan chronicle:key:generate --id=chronicle-key-2
# add the printed entry to signing.keys
php artisan chronicle:key:rotate chronicle-key-2      # boundary checkpoint at current head
# set CHRONICLE_ACTIVE_KEY=chronicle-key-2, deploy

The boundary checkpoint is what makes the epoch handover itself verifiable. When you retire a key, delete only its private_key — keep public_key in the ring forever, or every artifact it ever signed stops verifying.

Testing. Don't build fixture ledgers with record()->commit() in a loop, and never with raw factories. Use the shipper:

$seeded = LedgerSeeder::make()
    ->count(1000)
    ->checkpointEvery(100)
    ->action(fn (int $i) => "order.$i")
    ->subject(fn (int $i) => Order::factory()->create())
    ->seed();

It drives the real write path inside one transaction, so the result passes both IntegrityVerifier::verify() and CheckpointChainVerifier::verify() — which raw inserts will not.

Gotchas and Tips

  • Raw inserts silently corrupt the ledger. DB::table('chronicle_entries')->insert(...) or a factory bypasses canonicalization, hashing and chaining. Everything looks fine until the first chronicle:verify, which then fails on rows you can't fix without truncating. If you need bulk data, that's what LedgerSeeder is for.
  • Entries are immutable — design for it. You cannot update or delete a row without breaking the chain. That means no PII in metadata unless your erasure story is settled; use Chronicle's GDPR erasure path (which is designed to preserve verifiability) rather than DELETE. Audit your metadata schema for anything you might later be asked to remove.
  • Run the backfill before relying on incremental verify. On an existing ledger without head/count/link metadata, the incremental modes silently fall back to a full verify. That's safe but slow, and it's easy to misread as "incremental verify isn't working". Run chronicle:checkpoints:backfill once after upgrading.
  • Full verify is linear in ledger size. Don't put it in a health endpoint. Use --checkpoints-only --anchors for liveness and keep the full walk on a nightly schedule; --resume exists precisely because it can be interrupted.
  • Anchoring is opt-in and best-effort by design. An anchor failure never invalidates the checkpoint — good for availability, bad for your assurance guarantees if nobody's watching. Alert on the pending-anchor queue, not just on errors.
  • Concurrent appends contend on the chain head. Every commit links to the previous entry, so writes serialize. Recording inside long-running transactions or firing dozens of entries per request will show up as lock contention under load. Keep the record() block tight, and load-test the write path — not just the read path — before you rely on it in a hot loop.
  • Queued recording reorders your audit trail. If record() runs in a job, the entry lands at commit time, not at event time, and a job that exhausts its retries leaves a hole in the audit history. For high-value events, record synchronously and let slower side effects be queued.
  • signing.active must exist in signing.keys. A typo fails at signing time, deep in a scheduled command, not at boot. Assert it in a config test.
  • 1.9.x flat config still works (Chronicle adapts it to a single-key ring), but migrate to the signing.active + signing.keys shape — it's the only form that survives a rotation cleanly.
  • Sanctioned extension points, if the defaults don't fit: bind your own Chronicle\Contracts\ReferenceLookup, write a custom signing provider (KMS/HSM/GCP/Vault — sign remotely, verify locally against a cached public key to keep verification offline), or a custom anchor sink. The official companions (kms-aws, anchor-s3 with Object Lock) cover the two most common compliance asks; reach for those before writing your own.
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.
takashato/laravel-vietqr
aitor24/laralang
kraenzle-ritter/entity-relations
dindigital/din-image
pomirleanu/image-colors
sawmainek/laravel-api-generator
delatbabel/jsonresponses
mouhamedfd/permissions-generator
eduplus/qr
laravel2580/idehelpers
delatbabel/viewpages
anavel/gettext
muhamadrezaar/highcharts
delatbabel/keylists
frankkessler/salesforce-laravel-oauth2-rest
anavel/foundation
wdmtech/enviricons
eii/laravel-captcha
muka/shape-reader
tdt/json