ahmed3bead / laravel-tenant-audit
Multi-tenant audit logging for Laravel applications
Package info
github.com/ahmed3bead/laravel-tenant-audit
pkg:composer/ahmed3bead/laravel-tenant-audit
Requires
- php: ^8.1
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- pestphp/pest: ^2.0|^3.0
- pestphp/pest-plugin-laravel: ^2.0|^3.0
- phpunit/phpunit: ^10.0|^11.0|^12.0
README
laravel-tenant-audit
Multi-tenant audit logging for Laravel. Tracks model created, updated, deleted, restored, and custom business events per tenant, with full control over attribute filtering, actor resolution, and storage.
- Polymorphic actor support — Admin, Vendor, Customer, or any user model
- Per-model attribute allowlists and excludelists
- Per-model and global event toggles
- Custom business events (
approved,exported,impersonated, …) - Configurable column names, DB connection, and table
- Optional async queue support
- Laravel morph map aware
- Auto-pruning via
model:prune
Supports: Laravel 10 / 11 / 12 / 13 · PHP 8.1+
Installation
composer require ahmed3bead/laravel-tenant-audit
Publish and migrate
# Publish config php artisan vendor:publish --tag=tenant-audit-config # Publish migration then run it php artisan vendor:publish --tag=tenant-audit-migrations php artisan migrate # Optional: publish a TenantResolver stub php artisan vendor:publish --tag=tenant-audit-stubs
Quick start
Add the Auditable trait and AuditableContract interface to any model you want to track:
use Ahmed3bead\TenantAudit\Concerns\Auditable; use Ahmed3bead\TenantAudit\Contracts\AuditableContract; class Order extends Model implements AuditableContract { use Auditable; }
That's it. Every created, updated, deleted, and restored event is now logged to tenant_audit_logs.
Configuration
All options live in config/tenant-audit.php.
Tenant resolution
Implement TenantResolverContract and point the config to it:
// app/Services/TenantResolver.php use Ahmed3bead\TenantAudit\Contracts\TenantResolverContract; class TenantResolver implements TenantResolverContract { public function getTenantId(): int|string|null { return tenant()?->getTenantKey(); // stancl/tenancy example } }
// config/tenant-audit.php 'tenant_resolver' => \App\Services\TenantResolver::class,
Actor resolution — multiple user models
The package stores the actor as a polymorphic pair (user_type + user_id), so Admin, Vendor, Customer, and any other user model are all supported without ambiguity.
Default behaviour — uses auth()->user() automatically. No config needed for a single-guard app.
Multi-guard / multi-model — set user_resolver to a callable:
// config/tenant-audit.php 'user_resolver' => fn () => match (true) { auth('admin')->check() => ['type' => \App\Models\Admin::class, 'id' => auth('admin')->id()], auth('vendor')->check() => ['type' => \App\Models\Vendor::class, 'id' => auth('vendor')->id()], auth('customer')->check() => ['type' => \App\Models\Customer::class, 'id' => auth('customer')->id()], default => null, },
Retrieve the actor from a log entry:
$log = AuditLog::find(1); $log->user; // returns Admin, Vendor, Customer, etc. — fully resolved $log->user_type; // "App\Models\Admin" $log->user_id; // 42
Attribute filtering
Allowlist — only audit specific attributes:
class Order extends Model implements AuditableContract { use Auditable; // Only 'status' and 'total' appear in audit logs — everything else ignored protected array $auditable = ['status', 'total']; }
Excludelist — never audit specific attributes on this model:
protected array $auditExclude = ['internal_notes', 'cache_key'];
Global excludelist — applies to every model, regardless of model-level settings:
// config/tenant-audit.php 'excluded_attributes' => [ 'password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes', ],
When both
$auditableand$auditExcludeare set,$auditablewins — only allowlisted keys are evaluated.
Event control
Global — disable specific events for all models:
// config/tenant-audit.php 'events' => [ 'created' => true, 'updated' => true, 'deleted' => true, 'restored' => true, 'forceDeleted' => false, // opt-in ],
Per-model — override the global list for a specific model:
class ShipmentOrder extends Model implements AuditableContract { use Auditable; // Only log create and delete — skip updated and restored protected array $auditEvents = ['created', 'deleted']; }
Custom events
Log any business action that is not an Eloquent lifecycle event:
// On the model instance $order->auditEvent('approved'); $order->auditEvent('status_changed', oldValues: ['status' => 'pending'], newValues: ['status' => 'approved'], metadata: ['reviewed_by' => 'compliance-team'], ); // Via the Facade (useful when you don't have a model instance) use Ahmed3bead\TenantAudit\Facades\TenantAudit; TenantAudit::log('impersonated', $targetUser, metadata: ['by_admin_id' => 1]); TenantAudit::log('exported', $report, tenantId: 'acme', metadata: ['format' => 'csv']);
Strict mode — allowlist permitted custom event names to catch typos:
// config/tenant-audit.php 'custom_events' => ['approved', 'rejected', 'exported', 'impersonated'],
Leave custom_events empty to allow any event name (permissive mode, the default).
Runtime audit control
Disable on a single instance
// This update is not logged $order->disableAudit()->update(['cache_key' => '...']); // Re-enable for subsequent operations $order->enableAudit()->update(['status' => 'active']);
Disable for a block of code
// Bulk seed or migration — no audit noise Order::withoutAudit(function () { Order::query()->update(['synced_at' => now()]); }); // Logging resumes normally after the closure — even if it throws
Querying audit logs
use Ahmed3bead\TenantAudit\Models\AuditLog; // All logs for a tenant AuditLog::forTenant('acme')->latestFirst()->get(); // All logs for a specific model instance $order->auditLogs()->latestFirst()->get(); // All logs by a specific actor type + id AuditLog::forUser(Admin::class, 1)->get(); AuditLog::forUser($admin)->get(); // model instance shorthand // Filter by event AuditLog::byEvent('approved')->forTenant('acme')->get(); // Filter by auditable model AuditLog::forModel(Order::class, $order->id)->get(); // Ordering AuditLog::latestFirst()->limit(50)->get(); AuditLog::oldestFirst()->get();
Database schema
Single table: tenant_audit_logs
| Column | Type | Notes |
|---|---|---|
id |
bigint | PK |
tenant_id |
string | nullable, indexed |
user_type |
string | nullable — morph type of actor |
user_id |
bigint | nullable — actor PK |
event |
string(50) | created / updated / deleted / restored / custom |
auditable_type |
string | morph type of subject model |
auditable_id |
bigint | subject model PK |
old_values |
json | nullable |
new_values |
json | nullable |
ip_address |
inet | nullable |
user_agent |
text | nullable |
metadata |
json | nullable — free-form extras |
created_at |
timestamp | no updated_at |
Swapping the AuditLog model
Extend AuditLog and point the config to your class:
// app/Models/MyAuditLog.php use Ahmed3bead\TenantAudit\Models\AuditLog; class MyAuditLog extends AuditLog { // add custom scopes, relations, accessors… }
// config/tenant-audit.php 'model' => \App\Models\MyAuditLog::class,
Pruning old logs
Enable pruning in config:
// config/tenant-audit.php 'prune_after_days' => 90,
Then schedule Laravel's built-in prune command:
// routes/console.php (Laravel 11+) Schedule::command('model:prune')->daily();
Async queue support
Write audit logs asynchronously to keep model events fast:
// config/tenant-audit.php 'queue' => true, 'queue_connection' => 'redis', 'queue_name' => 'audit',
Requires a working queue driver. The sync driver will process jobs inline.
Disable IP / User-Agent capture
// config/tenant-audit.php 'capture_ip' => false, 'capture_user_agent' => false,
Testing
composer test # run all tests composer test:coverage # run with coverage (min 80%) composer format # run Laravel Pint
License
MIT — Ahmed Abead
