定制 softartisan/laravel-audit-events 二次开发

按需修改功能、优化性能、对接业务系统,提供一站式技术支持

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

softartisan/laravel-audit-events

最新稳定版本:v2.1.0

Composer 安装命令:

composer require softartisan/laravel-audit-events

包简介

Tamper-evident audit trail for Laravel: automatic Eloquent model auditing, free-standing semantic events, an HMAC hash-chain integrity guarantee, and cold archiving for long-term compliance.

README 文档

README

Latest Version on Packagist Tests Total Downloads

A production-ready Laravel package that automatically audits Eloquent model changes, records free-standing semantic events, guarantees cryptographic integrity of every audit record, and provides a cold archiving strategy for long-term retention — designed for ERP systems and compliance-sensitive applications.

Why another audit package?

spatie/laravel-activitylog and owen-it/laravel-auditing are excellent and, for most apps, the right choice. Reach for this package when you specifically need:

  • Tamper-evidence — an HMAC hash-chain per record with a verify command, not just a log table anyone with DB access can silently edit. (threat model)
  • One model for everything — automatic model audits and free-standing semantic events (user.logged_in, csv.exported, impersonation.started) in a single, queryable table.
  • Compliance retention built in — cold archiving to a DB table or JSONL files, per-tenant retention, and pruning.
  • Multi-tenant by accident, not by coupling — writes to the current connection, zero dependency on any tenancy package (works the same in a plain Laravel app).

If you don't need integrity guarantees or free-standing events, the established packages are lighter. We'd rather you pick the right tool.

v2.0 — breaking changes: Package renamed from softartisan/laravel-model-audits to softartisan/laravel-audit-events. See the Upgrade Guide below.

📖 Looking for "how do I do X?" See the scenario-driven Use-Case Cookbook → USE-CASES.md — every use case (auto-audit, manual/free events, jobs, revert, export, multi-tenant isolation, impersonation, integrity, retention, frontend rendering…) with copy-paste code.

Features

  • Automatic audit trail for created, updated, deleted, restored Eloquent events
  • AuditContext::actingAs() — inject the causer in queue jobs where Auth::id() is null
  • ModelAudit::record() — record free-standing events (login, export, PDF…) without an Eloquent anchor
  • saveHistory() — manually record a semantic event bound to a specific model
  • TracksRelationChanges — track pivot/sync relation changes (many-to-many)
  • Deep JSON diff — recursively diff array/JSON fields to pinpoint sub-key changes
  • Global + per-model attribute masking (passwords, tokens, credit cards…)
  • Cryptographic integrity — HMAC-SHA256 signature + hash chain per model, tamper-evident audit trail
  • Cold archiving — move old records to a dedicated table or daily JSONL files instead of deleting them
  • Configurable pruning with per-tenant retention
  • audit-events:stats — audit statistics at a glance
  • audit-events:verify — bulk integrity verification
  • audit-events:archive — archive old records to cold storage
  • MCP server integration (optional, requires laravel/mcp)
  • PHP 8.4 · Laravel 12 · Pest · PHPStan level 5

Table of Contents

Installation

composer require softartisan/laravel-audit-events

Publish the config and run the migrations:

php artisan vendor:publish --tag="laravel-audit-events-config"
php artisan migrate

Upgrade from v1.x

Breaking changes in v2.0:

  • Package renamed: softartisan/laravel-model-auditssoftartisan/laravel-audit-events
  • Namespace changed: SoftArtisan\LaravelModelAuditsSoftArtisan\LaravelAuditEvents
  • Config key changed: model-auditsaudit-events
  • Artisan command renamed: model-audits:statsaudit-events:stats
  • Audit table renamed: model_auditsaudit_events

Step 1 — update your composer.json:

composer require softartisan/laravel-audit-events:^2.0

Step 2 — update all use statements and config references in your app:

// Before
use SoftArtisan\LaravelModelAudits\Concerns\IsAuditable;
config('model-audits.table_name');

// After
use SoftArtisan\LaravelAuditEvents\Concerns\IsAuditable;
config('audit-events.table_name');

Step 3 — publish the new config and run migrations:

php artisan vendor:publish --tag="laravel-audit-events-config"
php artisan migrate

The bundled rename_model_audits_to_audit_events_table migration renames model_audits → audit_events safely (checks table existence before acting). To keep the old table name, override the config:

// config/audit-events.php
'table_name' => 'model_audits',

Basic Usage

Attach IsAuditable to a model

use SoftArtisan\LaravelAuditEvents\Concerns\IsAuditable;

class Invoice extends Model
{
    use IsAuditable;
}

Every created, updated, deleted, and restored event now produces an audit record automatically.

Querying Audit History

$invoice->audits()->get();               // all audits for this model
$invoice->audits()->latest()->first();   // most recent audit

// Filtered by event type
$invoice->getCreatedHistory()->get();
$invoice->getUpdatedHistory()->get();
$invoice->getDeletedHistory()->get();
$invoice->getRestoredHistory()->get();
$invoice->getAuditHistory('invoice.sent')->get(); // any event name

// Diff between old and new values on an update audit
$audit = $invoice->getUpdatedHistory()->latest()->first();
$diff  = $audit->getDiff();
// ['amount' => ['old' => 100, 'new' => 250]]

Query scopes on ModelAudit

For querying across the whole audit_events table (not just one model's audits() relation), three local scopes are available:

use SoftArtisan\LaravelAuditEvents\Models\ModelAudit;

// Filter by event name (CRUD or semantic).
ModelAudit::whereEvent('asset.status_changed')->get();

// Filter by a key inside the JSON `context` column (portable across
// MySQL / PostgreSQL / SQLite via Laravel's `->` JSON path operator).
ModelAudit::whereContext('mission_id', 42)->get();

// Filter by the anchored model instance (uses the indexed morph columns).
ModelAudit::forAuditable($invoice)->get();

// Compose them freely.
ModelAudit::forAuditable($asset)
    ->whereEvent('asset.status_changed')
    ->whereContext('mission_id', 42)
    ->latest('created_at')
    ->get();

Restore a Model

Roll back a model to the state stored in a previous audit's old_values:

$audit = $invoice->audits()->latest()->first();
$audit->restore(); // forceFills old_values back onto the model and saves

Columns that no longer exist in the table are silently skipped.

Attribute Masking

Globally in config/audit-events.php:

'global_hidden' => [
    'password',
    'password_confirmation',
    'remember_token',
    'secret',
    'credit_card_number',
],

Per model — override getHiddenForAudit():

class Patient extends Model
{
    use IsAuditable;

    public function getHiddenForAudit(): array
    {
        return array_merge(parent::getHiddenForAudit(), [
            'ssn',
            'date_of_birth',
            'medical_record_number',
        ]);
    }
}

Masked attributes are stripped from both old_values and new_values before storage.

AuditContext — Queue Jobs

In queue jobs, Auth::id() is null because there is no active session. Use AuditContext::actingAs() to inject the causer manually.

use SoftArtisan\LaravelAuditEvents\AuditContext;

class ImportInvoicesJob implements ShouldQueue
{
    public function __construct(
        private readonly int $userId,
        private readonly string $batchId,
    ) {}

    public function handle(): void
    {
        AuditContext::actingAs($this->userId, [
            'source'   => 'import-job',
            'batch_id' => $this->batchId,
        ]);

        // All audits produced here will carry $this->userId as causer
        Invoice::create([...]);
        Invoice::find(42)->update([...]);

        AuditContext::reset(); // Always reset — prevents context bleed
    }
}

AuditContext is a static class. Reset is mandatory at the end of every job because PHP-FPM/Swoole workers reuse the same process.

Free Events — ModelAudit::record()

Record semantic events that have no Eloquent model anchor:

use SoftArtisan\LaravelAuditEvents\Models\ModelAudit;

// Authentication events
ModelAudit::record('user.logged_in',  ['ip' => request()->ip()], $user->id);
ModelAudit::record('user.logged_out', [], $user->id);

// Bulk operations
ModelAudit::record('csv.exported', [
    'resource' => 'fixed-assets',
    'count'    => 1500,
    'tenant'   => tenant('id'),
], $this->userId);

// Report generation
ModelAudit::record('pdf.generated', [
    'template'   => 'annual-report',
    'invoice_id' => $invoice->id,
], auth()->id());

Signature:

ModelAudit::record(
    string          $event,
    array           $context   = [],
    int|string|null $causerId  = null,
): ModelAudit

Free events are never filtered by the events whitelist.

saveHistory() — Manual Model Events

Record a custom semantic event bound to a specific model instance:

// Invoice sent to client
$invoice->saveHistory(
    event:     'invoice.sent',
    oldValues: [],
    newValues: [],
    context:   ['recipient' => 'client@example.com', 'channel' => 'email'],
);

// Status transition with diff
$invoice->saveHistory(
    event:     'invoice.approved',
    oldValues: ['status' => 'draft'],
    newValues: ['status' => 'approved'],
    context:   ['approver_id' => auth()->id()],
);

Signature:

public function saveHistory(
    string $event,
    array  $oldValues = [],
    array  $newValues = [],
    array  $context   = [],
): void

Not subject to the events whitelist.

TracksRelationChanges

Laravel does not emit Eloquent events when pivot tables are modified (sync, attach, detach). Use this trait alongside IsAuditable to track those changes manually.

use SoftArtisan\LaravelAuditEvents\Concerns\IsAuditable;
use SoftArtisan\LaravelAuditEvents\Concerns\TracksRelationChanges;

class Role extends Model
{
    use IsAuditable, TracksRelationChanges;
}
class RoleService
{
    public function syncPermissions(Role $role, array $permissionIds): void
    {
        $before = $role->permissions->pluck('name')->all();

        $role->permissions()->sync($permissionIds);

        $after = $role->fresh()->permissions->pluck('name')->all();

        $role->recordRelationAudit('permissions', $before, $after, [
            'actor_id' => auth()->id(),
        ]);
    }
}

The audit record event will be relation.synced. The old_values and new_values are keyed by the relation name:

{
  "old_values": { "permissions": ["read", "write"] },
  "new_values": { "permissions": ["read", "write", "delete"] }
}

Deep JSON Diff

When a model has a JSON/array column, getDiff() recursively diffs it to pinpoint exact sub-key changes:

// Before: settings = ['theme' => 'light', 'notifications' => ['email' => true, 'sms' => false]]
// After:  settings = ['theme' => 'dark',  'notifications' => ['email' => true, 'sms' => true]]

$diff = $audit->getDiff();
// [
//   'settings' => [
//     'old'  => ['theme' => 'light', 'notifications' => ['email' => true, 'sms' => false]],
//     'new'  => ['theme' => 'dark',  'notifications' => ['email' => true, 'sms' => true]],
//     'diff' => [
//       'theme'         => ['old' => 'light', 'new' => 'dark'],
//       'notifications' => [
//         'old'  => ['email' => true, 'sms' => false],
//         'new'  => ['email' => true, 'sms' => true],
//         'diff' => ['sms' => ['old' => false, 'new' => true]],
//       ],
//     ],
//   ],
// ]

Configure in config/audit-events.php:

'json_diff' => [
    'enabled'   => true,
    'max_depth' => 3,
],

Cryptographic Integrity

The integrity feature adds a tamper-evident HMAC-SHA256 signature to every audit record, plus a hash chain that links each record to its predecessor within the same model's history.

Setup

Step 1 — run the migration:

php artisan migrate
# Applies: add_signature_to_audit_events_table

Step 2 — enable in config/audit-events.php:

'integrity' => [
    'enabled'   => true,
    'key'       => null,       // null uses APP_KEY. Set a dedicated AUDIT_SIGNING_KEY for isolation.
    'algorithm' => 'sha256',   // Any PHP hash_hmac() algorithm
],

Step 3 (optional) — set a dedicated signing key in .env:

AUDIT_SIGNING_KEY=base64:your-32-byte-key-here

Then reference it in the published config:

'integrity' => [
    'enabled' => true,
    'key'     => env('AUDIT_SIGNING_KEY'),
],

How it works

Each new audit record receives:

  • signature (varchar 64): HMAC over a canonical JSON payload covering auditable_type, auditable_id, event, user_id, old_values, new_values, context, created_at, and previous_hash.
  • previous_hash (varchar 64): the signature of the immediately preceding record for the same (auditable_type, auditable_id) pair. null for the first record.

The hash chain scope is per model instance — two different Invoice records maintain independent chains, avoiding write contention. Free-standing events (no auditable) are chained by user_id.

Verifying a single record

$audit = Invoice::find(1)->audits()->latest()->first();

$audit->isSigned();       // true if the record has a non-null signature
$audit->verifySignature(); // true if the HMAC matches; false if tampered

Bulk verification

php artisan audit-events:verify

# Limit to a model
php artisan audit-events:verify --model="App\Models\Invoice"

# Limit to one instance
php artisan audit-events:verify --model="App\Models\Invoice" --id=42

# Date range
php artisan audit-events:verify --from=2025-01-01 --until=2025-12-31

# Stop on first failure
php artisan audit-events:verify --fail-fast

Exit codes: 0 = all signed records passed · 1 = tampered records found (or integrity disabled).

Records created before the feature was enabled are reported as unsigned (not tampered) and do not affect the exit code.

Key management

  • Use a dedicated key (AUDIT_SIGNING_KEY) separate from APP_KEY so that rotating your app key does not invalidate existing audit signatures.
  • Store the key in a secrets manager (AWS Secrets Manager, HashiCorp Vault). Do not store it only in .env for compliance-critical applications.
  • If you must rotate the signing key, re-sign historical records via a one-off artisan command (not provided — implement in your app with AuditSignatureService).

Cold Archiving

Archiving moves records older than a configurable threshold to cold storage, preserving them for legal/compliance purposes while keeping the hot audit_events table lean.

Setup

Step 1 — run the migration (database driver):

php artisan migrate
# Applies: create_audit_events_archive_table

Step 2 — enable in config/audit-events.php:

'archive' => [
    'enabled'            => true,
    'archive_after_days' => 90,         // Records older than 90 days
    'driver'             => 'database', // 'database' | 'json_file'
    'table_name'         => 'audit_events_archive',
    'path'               => null,       // Required for json_file driver; null = storage_path('audit-archives')
],

Step 3 — schedule the archive command:

// bootstrap/app.php
use Illuminate\Console\Scheduling\Schedule;

->withSchedule(function (Schedule $schedule) {
    $schedule->command('audit-events:archive')->weekly();
})

Database driver

Moves records in transactional batches (default 500/batch). Each batch:

  1. Bulk-inserts into audit_events_archive (with archived_at timestamp)
  2. Deletes from audit_events — only if the insert succeeded

The archive table has an identical schema to audit_events, plus archived_at. Signatures and hash chain columns (signature, previous_hash) are preserved.

JSON file driver

Appends records to daily JSONL files (one JSON object per line):

storage/audit-archives/audit_events_archive_2025_03_29.jsonl
storage/audit-archives/audit_events_archive_2025_03_30.jsonl

Each line is a complete JSON representation of the audit record plus archived_at. Files can be gzipped and uploaded to S3 for long-term storage.

'archive' => [
    'enabled' => true,
    'driver'  => 'json_file',
    'path'    => storage_path('audit-archives'), // or /mnt/cold-storage
],

Archive command options

# Preview without changes
php artisan audit-events:archive --dry-run

# Override threshold
php artisan audit-events:archive --days=365

# Override driver
php artisan audit-events:archive --driver=json_file

# Limit to one model type
php artisan audit-events:archive --model="App\Models\Invoice"

# Custom batch size
php artisan audit-events:archive --chunk=1000

Hash chain continuity after archiving

After archiving, the live table has a gap at the chain boundary. The next new audit record on the same model will reference the most recent remaining live record's signature as its previous_hash. The archived record retains its signature intact and can be cross-referenced manually.

When running audit-events:verify, a chain break at an archive boundary is expected. The verify command reports it as a gap rather than tampering.

Pruning

Pruning deletes records permanently. Use it for data that has no legal retention obligation.

'pruning' => [
    'enabled'      => true,
    'keep_for_days' => 365,
],

When enabled is true, the service provider auto-schedules model:prune daily. You can also schedule it manually:

Schedule::command('model:prune', [
    '--model' => [\SoftArtisan\LaravelAuditEvents\Models\ModelAudit::class],
])->daily();

Multi-tenant retention

keep_for_days is read dynamically at every pruning run (never cached), so multi-tenant applications can set different retention periods per tenant:

// Tenant A — standard
config(['audit-events.pruning.keep_for_days' => 365]);

// Tenant B — financial compliance (7 years)
config(['audit-events.pruning.keep_for_days' => 2555]);

Pruning vs. Archiving

Pruning Archiving
Records after operation Deleted permanently Preserved in cold storage
Compliance-safe Only if retention period met Yes
Hash chain Broken at deletion Intact in archive
Recommended for Non-sensitive operational data Financial, medical, legal records

Use pruning for high-volume, low-sensitivity events. Use archiving when records must be retained for legal or compliance reasons.

Artisan Commands

audit-events:stats

Display audit event statistics.

php artisan audit-events:stats

Output includes: total records, breakdown by event type, top 5 audited model classes, date range, table size (MySQL/PostgreSQL), and archive stats when archive.enabled = true.

audit-events:verify

Verify the cryptographic integrity of audit records. Requires integrity.enabled = true.

php artisan audit-events:verify [options]

Options:
  --model=CLASS    Limit to a specific auditable_type (FQCN)
  --id=ID          Limit to a specific auditable_id (requires --model)
  --from=DATE      Verify records created on or after this date (Y-m-d)
  --until=DATE     Verify records created on or before this date (Y-m-d)
  --fail-fast      Stop at the first failure

Exit codes: 0 = pass · 1 = tampered records or feature disabled.

audit-events:archive

Move old audit records to cold storage.

php artisan audit-events:archive [options]

Options:
  --days=N         Archive records older than N days (overrides config)
  --driver=NAME    Use 'database' or 'json_file' (overrides config)
  --dry-run        Show what would be archived without moving records
  --chunk=N        Records per batch (default: 500)
  --model=CLASS    Limit to a specific auditable_type (FQCN)

audit-events:stats (archive section)

When archive.enabled = true, the stats command adds an archive section:

Archive
  Archived records : 18 432
  Oldest archived  : 2024-01-03 09:12:00
  Newest archived  : 2025-12-31 23:59:00

Configuration Reference

// config/audit-events.php

return [

    // ── Database ──────────────────────────────────────────────────────────────

    'table_name'  => 'audit_events',
    'model_class' => \SoftArtisan\LaravelAuditEvents\Models\ModelAudit::class,

    // ── Column Mapping ────────────────────────────────────────────────────────
    //
    // morph_type options: 'string' (recommended), 'integer', 'uuid', 'ulid'

    'table_fields' => [
        'id'           => 'audit_id',
        'user_id'      => 'user_id',
        'event'        => 'event',
        'morph_prefix' => 'auditable',
        'morph_type'   => 'string',
        'url'          => 'url',
        'ip_address'   => 'ip_address',
        'user_agent'   => 'user_agent',
        'old_values'   => 'old_values',
        'new_values'   => 'new_values',
        'context'      => 'context',
    ],

    // ── Behaviour ─────────────────────────────────────────────────────────────

    'audit_on_create'  => true,
    'audit_on_update'  => true,

    // true  → remove all audits when a model is hard-deleted
    // false → keep audits and record a final "deleted" entry
    'remove_on_delete' => true,

    // Automatic Eloquent events whitelist.
    // saveHistory() and ModelAudit::record() always bypass this list.
    'events' => ['created', 'updated', 'deleted', 'restored'],

    // ── Security ──────────────────────────────────────────────────────────────

    'global_hidden' => [
        'password',
        'password_confirmation',
        'remember_token',
        'secret',
        'credit_card_number',
    ],

    // ── Deep JSON Diff ────────────────────────────────────────────────────────

    'json_diff' => [
        'enabled'   => true,
        'max_depth' => 3,
    ],

    // ── User Resolver ─────────────────────────────────────────────────────────

    'user' => [
        'guards'   => ['web', 'api', 'sanctum'],
        'resolver' => null, // callable — return the authenticated user instance
    ],

    // ── Pruning ───────────────────────────────────────────────────────────────

    'pruning' => [
        'enabled'       => false,
        'keep_for_days' => 365,
    ],

    // ── Cryptographic Integrity ───────────────────────────────────────────────

    'integrity' => [
        'enabled'   => false,
        'key'       => null,       // null falls back to APP_KEY
        'algorithm' => 'sha256',
    ],

    // ── Archiving ─────────────────────────────────────────────────────────────

    'archive' => [
        'enabled'            => false,
        'archive_after_days' => 90,
        'driver'             => 'database', // 'database' | 'json_file'
        'table_name'         => 'audit_events_archive',
        'path'               => null,       // null → storage_path('audit-archives')
    ],
];

Testing

composer test

Or directly with Pest:

./vendor/bin/pest
./vendor/bin/pest --coverage

Static analysis:

./vendor/bin/phpstan analyse --configuration phpstan.neon.dist --memory-limit=512M

Testing in your application

Disable integrity in tests to avoid APP_KEY dependency:

// tests/TestCase.php
protected function defineEnvironment($app): void
{
    $app['config']->set('audit-events.integrity.enabled', false);
}

Or enable it with a known key:

$app['config']->set('audit-events.integrity.enabled', true);
$app['config']->set('app.key', 'base64:'.base64_encode(str_repeat('x', 32)));

Changelog

See CHANGELOG.md.

License

MIT. See LICENSE.md.

统计信息

  • 总下载量: 0
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 0
  • 点击次数: 2
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-06-12

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固