graymatter / laravel-audit-chain
Immutable audit trail for Laravel Eloquent models via cryptographic hash chains. GDPR & NIS2 compliant.
Package info
github.com/graymattertechnology/laravel-audit-chain
pkg:composer/graymatter/laravel-audit-chain
Requires
- php: ^8.2
- illuminate/contracts: ^11.0|^12.0
- illuminate/database: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.9
- laravel/pint: ^1.27
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
- phpstan/phpstan: ^2.1
- rector/rector: ^2.3
README
An immutable audit trail for Laravel Eloquent models with optional cryptographic hash chain verification. Built for GDPR (articles 15, 17, 33) and NIS2 (article 21) compliance.
Features
- Two modes: Light activity log (
HasActivityLog) or full cryptographic hash chain (HasAuditTrail) - Immutable audit logs: Eloquent guards prevent updates/deletes on audit records
- Cryptographic hash chain: SHA-256 linked chain for tamper detection (full mode)
- GDPR compliance: Personal data annotation, anonymization, full subject data export
- SoftDeletes support: Auto-detects
SoftDeletes, capturesrestoredandforceDeletedevents - Custom events: Record business events via
$model->audit('published') - Batch grouping: Group related audit logs under a single UUID via
AuditChain::batch() - Free-form metadata: Attach context to audit logs via
AuditChain::context() - Disable logging:
AuditChain::withoutAudit()for seeds, imports, and migrations - Field control:
$auditInclude/$auditExclude+ auto-exclusion of$hiddenfields - User agent logging: Captures browser/client user agent automatically
- READ event capture: Opt-in
retrievedevent logging - Chain verification:
audit:verifycommand with--notifyfor cron scheduling - Log pruning:
audit:prunecommand with configurable retention - Notifications: Mail + webhook alerts (Slack, Teams, Discord compatible)
- Separate DB connection: Isolate audit data from application data
- Queue support: Offload audit recording to background jobs
Requirements
- PHP >= 8.2
- Laravel 11 or 12
Installation
composer require graymatter/laravel-audit-chain
Publish the config and migration:
php artisan vendor:publish --tag="audit-chain-config" php artisan vendor:publish --tag="audit-chain-migrations" php artisan migrate
Usage
Mode 1: Activity Log (Light)
Simple activity logging without hash chains. Audit logs have hash and prev_hash set to null.
use GrayMatter\AuditChain\Concerns\HasActivityLog; use GrayMatter\AuditChain\Contracts\Auditable; class Post extends Model implements Auditable { use HasActivityLog; }
Mode 2: Audit Trail (Full)
Cryptographic hash chain for immutable, verifiable audit logs.
use GrayMatter\AuditChain\Concerns\HasAuditTrail; use GrayMatter\AuditChain\Contracts\Auditable; class Licence extends Model implements Auditable { use HasAuditTrail; }
Custom Events
Record business-level events via the audit() method:
// Simple event $order->audit('shipped'); // Event with old/new values $order->audit('status_changed', oldValues: ['status' => 'pending'], newValues: ['status' => 'shipped'], );
Batch Grouping
Group related operations under a single batch UUID:
use GrayMatter\AuditChain\Facades\AuditChain; AuditChain::batch(function () { $order->audit('shipped'); $order->update(['status' => 'shipped']); $inventory->update(['quantity' => $inventory->quantity - 1]); }); // All 3 audit logs share the same batch_uuid
Free-Form Context
Attach metadata to all subsequent audit logs:
AuditChain::context(['source' => 'csv_import', 'file' => 'users.csv']); // All audit logs created after this will include this context User::create([...]); User::create([...]);
Disable Logging
Suppress audit logging for seeds, imports, or maintenance:
AuditChain::withoutAudit(function () { // No audit logs created during this callback User::factory()->count(1000)->create(); });
SoftDeletes
SoftDeletes is auto-detected. When present, restored and forceDeleted events are automatically captured in addition to standard CRUD events.
PersonalData Attribute
Annotate model properties as personal data using the PHP 8 attribute:
use GrayMatter\AuditChain\Attributes\PersonalData; class User extends Model implements Auditable { use HasAuditTrail; #[PersonalData(description: 'User email address')] public string $email; #[PersonalData] public string $name; }
Or use the $personalData array:
class User extends Model implements Auditable { use HasAuditTrail; protected array $personalData = ['email', 'name']; }
Field Control
Control which fields are audited:
class User extends Model implements Auditable { use HasAuditTrail; // Only audit these fields protected array $auditInclude = ['name', 'email', 'role']; // Or exclude specific fields protected array $auditExclude = ['last_login_at']; }
Fields in the model's $hidden array (passwords, tokens, etc.) are automatically excluded from audit values.
Accessing Audit Logs
$user->auditLogs; // MorphMany relation $user->auditLogs()->where('event', 'updated')->get();
GDPR
Data Export (Article 15)
$data = $user->exportPersonalData(); // ['email' => 'john@example.com', 'name' => 'John Doe']
Full Subject Access (Article 15)
Export personal data and the complete audit trail in one call:
$export = $user->exportFullSubjectData(); // [ // 'personal_data' => ['email' => 'john@example.com', 'name' => 'John Doe'], // 'audit_trail' => [ // ['id' => '...', 'event' => 'created', 'old_values' => [], 'new_values' => [...], ...], // ['id' => '...', 'event' => 'updated', ...], // ], // ]
The audit trail includes event, old/new values, personal data accessed, IP address, user agent, batch UUID, context, and timestamps — but excludes internal chain fields (hash, prev_hash).
Anonymization (Article 17)
$user->anonymize(); // email => '[ANONYMIZED]-42', name => '[ANONYMIZED]-42'
Uses saveQuietly() internally to avoid triggering audit events during anonymization. The model's primary key is appended to avoid UNIQUE constraint violations when anonymizing multiple records.
Read Tracking
Opt-in via config to capture retrieved events (Article 15 / Article 33 — who accessed personal data):
// config/audit-chain.php 'events' => [ 'log_reads' => true, // WARNING: very verbose ],
Chain Verification
Artisan Command
# Verify the entire chain php artisan audit:verify # Filter by model type php artisan audit:verify --type="App\Models\User" # Filter by model type and ID php artisan audit:verify --type="App\Models\User" --id=42 # Verify and send notifications on failure php artisan audit:verify --notify
Log Pruning
# Delete logs older than 90 days (default) php artisan audit:prune # Custom retention period php artisan audit:prune --days=365 # Prune only specific model type php artisan audit:prune --type="App\Models\User"
Automated Scheduling (Cron)
Schedule verification and pruning in your routes/console.php:
// routes/console.php (Laravel 11+) Schedule::command('audit:verify --notify')->hourly(); Schedule::command('audit:prune --days=90')->daily();
Notifications
When --notify is used and verification fails, notifications are sent based on config:
// config/audit-chain.php 'notifications' => [ 'channels' => ['mail', 'webhook'], 'mail_to' => [env('AUDIT_ALERT_EMAIL', '')], 'webhooks' => [ env('AUDIT_ALERT_WEBHOOK_1'), // Add more webhook URLs as needed ], ],
Webhook payloads are compatible with Slack, Microsoft Teams, Discord, and custom endpoints. Both text and content keys are included for cross-platform compatibility.
Programmatic API
use GrayMatter\AuditChain\Services\AuditChainService; $result = app(AuditChainService::class)->verifyChain(); // ['valid' => true, 'checked' => 150, 'errors' => []]
Configuration
// config/audit-chain.php return [ 'connection' => null, 'table' => 'audit_logs', 'drivers' => ['database'], 'chain_seed' => env('AUDIT_CHAIN_SEED', 'genesis'), 'queue' => [ 'enabled' => true, 'connection' => null, 'queue' => 'default', ], 'events' => [ 'log_reads' => false, ], 'anonymization' => [ 'replacement' => '[ANONYMIZED]', ], 'retention' => [ 'days' => 90, ], 'notifications' => [ 'channels' => ['mail'], 'mail_to' => [env('AUDIT_ALERT_EMAIL', '')], 'webhooks' => [], ], ];
| Key | Default | Description |
|---|---|---|
connection |
null |
DB connection for audit logs (separate recommended) |
table |
audit_logs |
Table name |
drivers |
['database'] |
Storage drivers |
chain_seed |
env('AUDIT_CHAIN_SEED', 'genesis') |
Secret seed for genesis hash |
queue.enabled |
true |
Dispatch audit recording to queue |
queue.connection |
null |
Queue connection |
queue.queue |
default |
Queue name |
events.log_reads |
false |
Capture retrieved events |
anonymization.replacement |
[ANONYMIZED] |
GDPR anonymization replacement string |
retention.days |
90 |
Days to keep logs (audit:prune) |
notifications.channels |
['mail'] |
Notification channels: mail, webhook |
notifications.mail_to |
[] |
Email addresses for mail alerts |
notifications.webhooks |
[] |
Webhook URLs (Slack, Teams, Discord, custom) |
Security
Chain Seed
Set AUDIT_CHAIN_SEED in your .env to a random, secret value. The seed is used to compute the genesis hash — the first link in the chain. A predictable genesis hash weakens tamper-evidence guarantees.
AUDIT_CHAIN_SEED=your-random-secret-value
Database User Permissions
For maximum immutability, use a dedicated database user for the audit connection with INSERT and SELECT only — no UPDATE or DELETE. Eloquent guards prevent modification at the application layer, but DB-level restrictions ensure immutability even if the application is compromised.
Timestamps
All audit timestamps are stored in UTC via now()->utc()->toDateTimeString() to ensure consistent hash computation across time zones.
Eloquent Immutability
The AuditLog model throws RuntimeException on updating and deleting events, preventing modification through Eloquent. The $fillable whitelist further restricts which attributes can be set.
Testing
composer test # or ./vendor/bin/pest
Full quality checks (Pint + Rector + PHPStan + Pest):
composer quality
Licensing
This package is dual-licensed:
- MIT License — for open source and non-commercial use
- Commercial License — required for proprietary/commercial use
See LICENSE and LICENSE_COMMERCIAL for details.