Chain Verification
A hash chain is only useful if you regularly verify it. AuditChain ships with an Artisan command and a programmatic API that re-derive every hash in the chain and flag any mismatches — catching tampering, data corruption, or accidental modifications.
The audit:verify 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
Flags
| Flag | Description |
|---|---|
--type= |
Scope verification to a single Eloquent model class (e.g. App\Models\Invoice). |
--id= |
Combined with --type, scope to a single model instance. |
--notify |
Send mail and/or webhook notifications if the chain is compromised. |
Filtered vs. Full Verification
AuditChain uses a global chain — a single hash chain that spans all auditable models. When you run audit:verify without filters, two checks are performed on every entry:
- Hash integrity — recompute the hash from the stored payload and compare it to the stored
hash. - Chain continuity — confirm that the entry's
prev_hashmatches the previous entry'shash.
When you pass --type or --id, only hash integrity is checked. Chain continuity is skipped because the filtered subset does not contain every link in the global chain.
How Verification Works
Verification processes audit logs in chunks of 1,000, ordered by id ascending (ULIDs are monotonic, so id ordering is sufficient). Verification terminates early after maxErrors failures (default: 100) to avoid processing an entire corrupted chain. For each entry the service:
- Reads the raw database values (bypassing Eloquent casts to avoid format discrepancies).
- Normalizes the
created_attimestamp to UTC. - Sorts the
old_values,new_values, andpersonal_data_accessedarrays for deterministic key order. - Builds the same canonical payload that was used at recording time.
- Computes
SHA-256over the JSON-encoded payload. - Compares the result against the stored
hash.
If this is an unfiltered run, it also checks that the entry's prev_hash equals the previous entry's hash (or the genesis hash for the first entry).
Checkpoint-Based Verification
If chained audit logs have been pruned with --include-chained, the audit_chain_state.checkpoint_hash stores the hash of the last deleted entry. Verification uses this checkpoint as the starting point instead of the genesis hash, so the remaining chain can still be verified successfully.
Programmatic API
Use AuditChainService directly when you need to verify the chain in application code — for example inside a health check endpoint or a custom command:
use GrayMatter\AuditChain\Services\AuditChainService; $service = app(AuditChainService::class); // Full chain $result = $service->verifyChain(); // Scoped to a model type $result = $service->verifyChain(type: 'App\Models\Invoice'); // Scoped to a single record $result = $service->verifyChain(type: 'App\Models\Invoice', id: '42'); // Limit early termination (default: 100 errors) $result = $service->verifyChain(maxErrors: 50);
The return value is an array:
[
'valid' => true, // bool -- true if no errors found
'checked' => 150, // int -- number of entries verified
'errors' => [], // array of ['id' => '...', 'message' => '...']
]
Example: Health Check Endpoint
Route::get('/health/audit-chain', function () { $result = app(AuditChainService::class)->verifyChain(); return response()->json($result, $result['valid'] ? 200 : 500); });
Scheduling Verification
For production systems, schedule audit:verify --notify so you are alerted automatically when tampering is detected. In Laravel 11+, add the schedule to routes/console.php:
use Illuminate\Support\Facades\Schedule; // Verify the chain every hour and notify on failure Schedule::command('audit:verify --notify')->hourly();
Adjust the frequency based on how much latency you can tolerate between a tamper event and detection. For high-security environments, consider running it every 15 minutes:
Schedule::command('audit:verify --notify')->everyFifteenMinutes();
Notifications
When --notify is passed and verification fails, AuditChain sends alerts through the channels configured in config/audit-chain.php:
'notifications' => [ 'channels' => ['mail', 'webhook'], 'mail_to' => [env('AUDIT_ALERT_EMAIL', '')], 'webhooks' => [ env('AUDIT_ALERT_WEBHOOK_1'), ], ],
- Mail — sends a
ChainCompromisednotification to the configured addresses. - Webhook — POSTs a JSON payload to each URL. The payload includes both
textandcontentkeys for compatibility with Slack, Microsoft Teams, Discord, and custom endpoints.
The webhook payload includes the error count, total entries checked, and up to 5 individual error details.
What to Do When Verification Fails
A verification failure means one or more audit log entries have been modified or the chain has been broken. Treat this as a security incident:
- Investigate immediately — the error output includes the ULID of each affected entry.
- Check database access logs — look for direct
UPDATEorDELETEqueries against the audit table. - Review application logs — check for unexpected deployments, migrations, or seed operations.
- Preserve evidence — take a database snapshot before making any changes.
- Notify stakeholders — depending on your compliance requirements (GDPR Art. 33, NIS2 Art. 21), you may need to report the incident.