laravel-model-integrity maintained by mueller-schmitz
Laravel Model Integrity
Immutable and versioned Eloquent models with a gapless, cryptographically verifiable history.
Status: early development (v0.1 in progress). Not ready for production use.
Scope
This package is tamper-evident, not tamper-proof:
- Changes through the application are either forbidden (
immutable) or recorded as a new version (versioned). - Any manipulation outside the application (direct SQL, restored backups, edited rows) is detected during verification, not prevented.
- The hash chain proves integrity, not completeness: operations that were never recorded are unknown to the chain. The state drift check compares the current model state against the last snapshot to surface such gaps.
Performance cost
All writes are appended to a single global chain. The chain head is locked with SELECT ... FOR UPDATE, which deliberately serialises recording writes across the whole application. This is the price for a gapless global sequence.
Requirements
- PHP ^8.3
- Laravel ^12.0 or ^13.0
- MySQL, MariaDB or PostgreSQL (SQLite works for local testing, but without database-level enforcement)
Installation
composer require mueller-schmitz/laravel-model-integrity
php artisan vendor:publish --tag=model-integrity-config
php artisan vendor:publish --tag=model-integrity-migrations
php artisan migrate
Usage
Add the HasIntegrity trait to a model:
use Illuminate\Database\Eloquent\Model;
use MuellerSchmitz\ModelIntegrity\Concerns\HasIntegrity;
class Invoice extends Model
{
use HasIntegrity;
protected string $integrityMode = 'versioned'; // 'versioned' | 'immutable'
protected string $integrityDeletes = 'record'; // 'forbid' | 'record'
protected array $integrityExcept = ['updated_at']; // not part of snapshots
protected array $integrityRelations = ['tags']; // related keys are part of every snapshot
protected int $integritySchemaVersion = 1; // bump when the snapshot structure changes
}
All properties are optional; defaults come from config/model-integrity.php.
What gets recorded
| Action | Version event |
|---|---|
create() |
created |
update() / save() with changes to recorded attributes |
updated |
delete() with $integrityDeletes = 'record' |
deleted |
restore() (soft deletes) |
restored |
forceDelete() (soft deletes) |
force_deleted |
recordRelation('tags') |
relation_synced |
save()anddelete()run in a database transaction: the model change and its version are committed together or not at all.- In
immutablemode any update throws anImmutableModelException. With$integrityDeletes = 'forbid'(default) deletes throw as well. - Saves that only change excluded attributes (e.g.
touch()) do not create a version. - Each version holds a full snapshot, read from the stored database row and normalized by the model casts (decimals as strings, dates in UTC, JSON sorted). Encrypted attributes are stored as ciphertext. Define casts for all attributes whose type matters; uncast values are stored as the database driver returns them.
Reason, context and actor
$invoice->withIntegrityReason('Customer complaint')
->withIntegrityContext(['ticket' => 'SUP-123'])
->update(['total' => '90.00']);
Reason and context apply to the next save() or delete() only.
The actor is the authenticated user. Where nobody is authenticated, for example in queue jobs or console commands, set it explicitly:
use MuellerSchmitz\ModelIntegrity\ModelIntegrity;
ModelIntegrity::actingAs($user, fn () => $invoice->update([...]));
ModelIntegrity::actingAs('system'); // a label instead of a model
The actor is reset after every queue job.
Relations
sync(), attach() and detach() fire no model events. Declare the relation in $integrityRelations and record the change in the same transaction:
DB::transaction(function () use ($post, $tagIds) {
$post->tags()->sync($tagIds);
$post->recordRelation('tags');
});
History
$invoice->integrityVersions()->get(); // oldest first
Limits
The package records what goes through Eloquent model events. These bypass it and are not recorded:
- mass updates and deletes (
Invoice::where(...)->update(...),DB::table(...)) saveQuietly(),Model::withoutEvents()increment()/decrement()run outside thesave()transaction (still recorded, but not atomically)- a model class that overrides
save()ordelete()itself replaces the transactional wrapper of the trait
Such changes are not prevented, but they are detected: the state drift check compares the current row with the last snapshot.
Model and integrity tables must use the same database connection, otherwise both cannot be written in one transaction.
Hash format
Every version stores the hash format it was created with (hash_format). A released format never changes; new rules always get a new format number. This section specifies format 1 so that hashes can be recomputed independently of this package, for example by an auditor.
Envelope
The hash is the lowercase hex SHA-256 of the canonical JSON encoding of this envelope, built from the stored version row:
| Field | Value |
|---|---|
format |
1 (integer) |
sequence |
global sequence number (integer) |
versionable_type |
morph class of the model (string) |
versionable_id |
model key (string) |
version |
version per model, starting at 1 (integer) |
event |
created, updated, deleted, restored, relation_synced or a custom event (string) |
schema_version |
snapshot schema version (integer) |
snapshot |
full model state (object) |
prev_hash |
hash of the previous version of the same model, null for version 1 |
global_prev_hash |
hash of the previous entry of the global chain, null for sequence 1 |
actor_type, actor_id |
who made the change (string or null) |
reason |
reason for the change (string or null) |
context |
additional context (object or null) |
created_at |
UTC timestamp, e.g. 2026-09-28T10:05:00.123456Z (string) |
No other fields are allowed. The database id is not part of the hash.
Canonical JSON
- Object keys are sorted recursively by Unicode code point (equal to UTF-8 byte order); arrays keep their order.
- No insignificant whitespace.
- UTF-8 output. Only characters JSON requires are escaped:
",\and control characters below U+0020 (\b,\t,\n,\f,\r, otherwise lowercase\u00xx). Slashes, non-ASCII characters and U+2028/U+2029 are not escaped. - Values:
null, booleans, integers and strings only. Decimals and floats are stored as strings, dates as UTC ISO 8601 strings with microseconds. - An empty object and an empty array are both encoded as
[]. - Binary (non UTF-8) values are not supported and must be excluded from snapshots.
Recomputing a hash
These rules match the defaults of common JSON libraries. With Python, for an envelope stored in envelope.json:
python3 -c "import hashlib, json; e = json.load(open('envelope.json', encoding='utf-8')); print(hashlib.sha256(json.dumps(e, sort_keys=True, separators=(',', ':'), ensure_ascii=False).encode('utf-8')).hexdigest())"
Reference envelopes with their canonical strings and hashes are part of the test suite in the repository (tests/Fixtures/hash-format-1.json).
License
MIT. See LICENSE.md.