lunnar / laravel-audit-logging
Comprehensive audit logging for Laravel: model events, HTTP requests, outgoing API calls, and request tracing with integrity verification
Package info
github.com/BeakSoftware/laravel-audit-logging
pkg:composer/lunnar/laravel-audit-logging
Requires
- php: ^8.2
- illuminate/database: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
Requires (Dev)
- orchestra/testbench: ^9.0
- pestphp/pest: ^2.0|^3.0
README
Comprehensive audit logging for Laravel: model events, HTTP requests, outgoing API calls, and request tracing with integrity verification.
Features
- 🔄 Automatic logging of
created,updated, anddeletedevents - 🎯 Per-model configuration via static properties
- 🔗 Auto-detection of
BelongsTorelationships as parent subjects - 🔒 Automatic sanitization of sensitive data (passwords, tokens, etc.)
- 🔐 HMAC checksum for data integrity verification
- 📡 HTTP request logging with full request/response capture
- 📤 Outgoing HTTP request logging (Laravel HTTP client)
- 🔍 Request tracing via
reference_idlinking requests to audit events - 🗑️ Separate configurable retention policies for events, requests, and outgoing requests
- 👁️ Event levels for visibility control (show different events to different user types)
Installation
composer require lunnar/laravel-audit-logging
Publish the config and migrations:
php artisan vendor:publish --tag=audit-logging-config php artisan vendor:publish --tag=audit-logging-migrations php artisan migrate
Add the AUDIT_KEY to your .env file:
AUDIT_KEY=your-secure-random-string-here
Generate a secure key:
php artisan tinker --execute="echo bin2hex(random_bytes(32));"
Usage
Add the HasAuditLogging trait to any model you want to audit:
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Lunnar\AuditLogging\Concerns\HasAuditLogging; class Product extends Model { use HasAuditLogging; /** * Fields to exclude from audit payload. */ protected static array $auditExclude = ['id', 'created_at', 'updated_at', 'deleted_at']; /** * Fields to include in audit messageData (for human-readable logs). */ protected static array $auditMessageFields = ['name', 'price']; }
That's it! Now all create, update, and delete operations on Product will be automatically logged.
Model Configuration
Per-model configuration is done via static properties:
| Property | Type | Default | Description |
|---|---|---|---|
$auditExclude |
array |
['id', 'created_at', 'updated_at', 'deleted_at'] |
Fields to exclude from payload |
$auditMessageFields |
array |
[] |
Fields for messageData (supports ['field' => 'accessor'] syntax) |
$auditIgnoreChanges |
array |
['updated_at'] |
Fields to ignore when detecting changes |
$auditEvents |
array |
['created', 'updated', 'deleted'] |
Which events to log |
$auditEventPrefix |
string |
from morph map/table | Event prefix (e.g., product) |
$auditSubjectType |
string |
from morph map/table | Subject type for audit entries |
$auditAdditionalSubjects |
array |
[] |
Additional related subjects (manual) |
$auditAutoParentSubjects |
bool |
true |
Auto-detect BelongsTo relationships as parent subjects |
$auditExcludeParents |
array |
[] |
BelongsTo relationships to exclude from auto-detection |
$auditLevel |
int |
from config | Default visibility level for this model's audit events |
Example with All Options
use App\Enums\AuditLevel; class User extends Model { use HasAuditLogging; // Exclude sensitive fields from the payload protected static array $auditExclude = [ 'id', 'password', 'remember_token', 'email_verified_at', 'created_at', 'updated_at', 'deleted_at' ]; // Use accessor for masked email in message data protected static array $auditMessageFields = [ 'name', 'email' => 'email_masked' // Uses $user->email_masked accessor ]; // Don't log changes to these fields protected static array $auditIgnoreChanges = ['updated_at', 'last_login_at']; // Only log create and delete events protected static array $auditEvents = ['created', 'deleted']; // Default visibility level for audit events on this model protected static int $auditLevel = AuditLevel::OWNER; }
Parent Subjects (BelongsTo Relationships)
By default, the trait automatically detects all BelongsTo relationships and includes them as parent subjects in audit entries. This requires your relationship methods to have a BelongsTo return type hint.
class Product extends Model { use HasAuditLogging; // These relationships are automatically detected and included as parent subjects public function organization(): BelongsTo { return $this->belongsTo(Organization::class); } public function category(): BelongsTo { return $this->belongsTo(Category::class); } }
When a Product is created/updated/deleted, the audit log will automatically include:
- Primary subject:
products(the product itself) - Parent subject:
organizations(fromorganization()) - Parent subject:
categories(fromcategory())
Excluding Specific Relationships
To exclude certain relationships from auto-detection:
class Product extends Model { use HasAuditLogging; // Don't include these relationships in audit logs protected static array $auditExcludeParents = ['createdBy', 'country']; public function organization(): BelongsTo { return $this->belongsTo(Organization::class); } public function createdBy(): BelongsTo // Excluded { return $this->belongsTo(User::class); } }
Disabling Auto-Detection
To disable automatic parent detection entirely:
class Product extends Model { use HasAuditLogging; protected static bool $auditAutoParentSubjects = false; }
Manual Additional Subjects
You can also manually specify additional subjects (useful when auto-detection isn't possible or for non-BelongsTo relationships):
class Role extends Model { use HasAuditLogging; protected static array $auditMessageFields = ['name']; // Manually specify additional subjects protected static array $auditAdditionalSubjects = [ [ 'type' => 'organizations', // Subject type 'foreign_key' => 'organization_id', // Foreign key on this model 'role' => 'parent' // Role in the audit entry ], ]; }
Customizing Subject Display
When retrieving audit log subjects, you can customize how each subject model is formatted for display by implementing a toAuditSubject() method on your model:
class User extends Model { use HasAuditLogging; /** * Customize how this model appears in audit log subject displays. */ public function toAuditSubject(): array { return [ 'id' => $this->id, 'name' => $this->name, 'email' => $this->email_masked, // Use masked email for privacy 'avatar_url' => $this->avatar_url, ]; } }
Access the formatted subject via the formatted_subject attribute on AuditLogSubject:
use Lunnar\AuditLogging\Models\AuditLogEvent; $event = AuditLogEvent::with('subjects.subject')->first(); foreach ($event->subjects as $subject) { // Returns the result of toAuditSubject() if defined, null otherwise $formatted = $subject->formatted_subject; if ($formatted) { echo "User: {$formatted['name']} ({$formatted['email']})"; } }
This is useful for:
- Hiding sensitive data (e.g., masked emails, partial phone numbers)
- Including computed attributes or accessors
- Providing a consistent display format across your application
- Avoiding exposing internal model structure to API consumers
Temporarily Disable Logging
Product::withoutAuditLogging(function () { Product::create([...]); // No audit log Product::find(1)->update([...]); // No audit log });
Temporarily Override Audit Level
You can temporarily override the audit level for operations within a callback:
use App\Enums\AuditLevel; // Override level for specific operations Product::withAuditLevel(AuditLevel::DEVELOPER, function () { Product::create([...]); // Logged at level 100 (DEVELOPER) Product::find(1)->update([...]); // Logged at level 100 (DEVELOPER) }); // The model's default level (or config default) is restored after the callback Product::create([...]); // Logged at model's default level
This is useful when you want to log certain operations at a different visibility level than the model's default, such as system-triggered changes that should only be visible to developers.
Temporarily Override Event Type
You can temporarily override the event type (created, updated, deleted) for operations within a callback:
// Force a 'created' event to log as 'init' Product::withAuditEventType('init', fn () => Product::create([ 'name' => 'New Product', ])); // Logs as 'product.init' instead of 'product.created' // Force an 'updated' event to log as 'created' (e.g., for upsert scenarios) Product::withAuditEventType('created', fn () => $product->update([ 'name' => 'Updated Name', ])); // Logs as 'product.created' instead of 'product.updated' // Combine with withAuditLevel for full control Product::withAuditLevel(AuditLevel::DEVELOPER, fn () => Product::withAuditEventType('system_init', fn () => Product::create([...])) ); // Logs as 'product.system_init' at level 100
This is useful for:
- Semantic event naming (e.g.,
initfor first-time setup vscreatedfor regular creation) - Upsert scenarios where you want consistent event types
- Custom lifecycle events that don't map to standard CRUD operations
Manual Audit Entries
You can also write audit entries manually:
use Lunnar\AuditLogging\Support\Audit; Audit::write( event: 'user.password_reset', subjects: [ ['subject_type' => 'users', 'subject_id' => $user->id, 'role' => 'primary'], ], messageData: ['email' => $user->email], payload: ['reset_method' => 'email'], level: 50, // Optional: set visibility level );
Event Levels
Event levels allow you to control the visibility of audit log entries based on viewer permissions. This is useful when you want to show different events to different user types (e.g., end-users, organization owners, developers).
How It Works
Each audit event has a level field (integer, 0-255). Lower levels are more visible, higher levels are more restricted. When not specified, events default to level 0 (configurable via default_level).
Defining Your Level Scheme
Define your own level constants in your application based on your needs:
// app/Enums/AuditLevel.php class AuditLevel { public const USER = 0; // Visible to end-users public const OWNER = 50; // Visible to organization owners public const DEVELOPER = 100; // Visible to developers/admins }
Writing Events with Levels
use Lunnar\AuditLogging\Support\Audit; use App\Enums\AuditLevel; // User-visible event (level 0) Audit::write( event: 'document.downloaded', subjects: [...], level: AuditLevel::USER, ); // Owner-visible event (level 50) Audit::write( event: 'subscription.renewed', subjects: [...], level: AuditLevel::OWNER, ); // Developer-only event (level 100) Audit::write( event: 'cache.cleared', subjects: [...], level: AuditLevel::DEVELOPER, );
Setting Default Level on Models
You can set a default level for all audit events on a model using the $auditLevel property:
use App\Enums\AuditLevel; class InternalNote extends Model { use HasAuditLogging; // All audit events for this model default to DEVELOPER level protected static int $auditLevel = AuditLevel::DEVELOPER; }
You can also temporarily override the level using withAuditLevel():
// Log this specific operation at a different level Product::withAuditLevel(AuditLevel::DEVELOPER, function () { // System-triggered update, only visible to developers Product::find(1)->update(['sync_status' => 'completed']); });
Querying by Level
Use the forLevel() scope to filter events based on the viewer's permission level:
use Lunnar\AuditLogging\Models\AuditLogEvent; use App\Enums\AuditLevel; // End-user sees only level 0 events $userEvents = AuditLogEvent::forLevel(AuditLevel::USER)->get(); // Organization owner sees level 0-50 events $ownerEvents = AuditLogEvent::forLevel(AuditLevel::OWNER)->get(); // Developer sees all events (level 0-100) $allEvents = AuditLogEvent::forLevel(AuditLevel::DEVELOPER)->get(); // Get events at exactly a specific level $ownerOnlyEvents = AuditLogEvent::atLevel(AuditLevel::OWNER)->get();
Configuration
Set the default level for events in config/audit-logging.php:
'default_level' => 0, // Events without explicit level get this value
Querying Audit Log Events
use Lunnar\AuditLogging\Models\AuditLogEvent; // Get all events for a specific model $events = AuditLogEvent::forSubject($product)->latest('created_at')->get(); // Get all events by a specific actor $events = AuditLogEvent::forActor($userId)->get(); // Get all events for a specific event type $events = AuditLogEvent::forEvent('product.created')->get(); // Get all events matching an event pattern $events = AuditLogEvent::forEventLike('product.%')->get(); // Get all events for a specific request (via reference_id) $events = AuditLogEvent::forReferenceId($referenceId)->get(); // Get events at or below a specific level (for visibility filtering) $events = AuditLogEvent::forLevel(50)->get(); // Get events at exactly a specific level $events = AuditLogEvent::atLevel(50)->get(); // Get the HTTP request associated with an event $event = AuditLogEvent::first(); $request = $event->request(); // Returns AuditLogRequest or null
Request Logging
HTTP requests are automatically logged for all routes in the web and api middleware groups. The middleware runs after authentication, so it knows whether a user is logged in.
Configuration
In config/audit-logging.php:
'request_logging' => [ 'only_authenticated' => true, // Only log requests from authenticated users ],
When only_authenticated is true, requests from unauthenticated users are completely skipped (no database operations). This helps filter out bot traffic and reduces database load.
Custom Route Groups
For custom middleware groups, use the audit.requests middleware alias:
Route::middleware(['custom-auth', 'audit.requests'])->group(function () { // routes });
Querying Request Logs
HTTP requests are logged to the audit_log_requests table.
use Lunnar\AuditLogging\Models\AuditLogRequest; // Get all requests for a specific reference ID $requests = AuditLogRequest::forReferenceId($referenceId)->get(); // Get all requests by a specific actor $requests = AuditLogRequest::forActor($userId)->get(); // Get all requests for a specific route $requests = AuditLogRequest::forRoute('api.products.store')->get(); // Get all requests with a specific HTTP method $requests = AuditLogRequest::forMethod('POST')->get(); // Get all failed requests (4xx and 5xx) $requests = AuditLogRequest::failed()->get(); // Get all successful requests (2xx) $requests = AuditLogRequest::successful()->get(); // Get the audit events associated with a request $request = AuditLogRequest::first(); $events = $request->events(); // Returns Collection of AuditLogEvent
Outgoing Request Logging
All outgoing HTTP requests made via Laravel's HTTP client (Http facade) are automatically logged. This is useful for tracking API calls to external services.
Configuration
In config/audit-logging.php:
'outgoing_request_logging' => [ 'enabled' => true, 'exclude_urls' => [ 'https://api.example.com/health*', // Exclude health checks '*localhost*', // Exclude local requests ], ],
The exclude_urls option supports wildcard patterns using *.
Querying Outgoing Request Logs
use Lunnar\AuditLogging\Models\AuditLogOutgoingRequest; // Get all outgoing requests for a specific reference ID $requests = AuditLogOutgoingRequest::forReferenceId($referenceId)->get(); // Get all outgoing requests matching a URL pattern $requests = AuditLogOutgoingRequest::forUrl('api.stripe.com')->get(); // Get all outgoing requests with a specific HTTP method $requests = AuditLogOutgoingRequest::forMethod('POST')->get(); // Get all failed outgoing requests (4xx, 5xx, or connection errors) $requests = AuditLogOutgoingRequest::failed()->get(); // Get all successful outgoing requests (2xx) $requests = AuditLogOutgoingRequest::successful()->get(); // Get the audit events associated with an outgoing request (via reference_id) $request = AuditLogOutgoingRequest::first(); $events = $request->events(); // Returns Collection of AuditLogEvent
Linking Outgoing Requests to Incoming Requests
Outgoing requests are automatically linked to the incoming HTTP request via reference_id. This allows you to trace which external API calls were made during a specific user request:
$referenceId = '550e8400-e29b-41d4-a716-446655440000'; // Get the incoming request $incomingRequest = AuditLogRequest::forReferenceId($referenceId)->first(); // Get all outgoing requests made during that request $outgoingRequests = AuditLogOutgoingRequest::forReferenceId($referenceId)->get(); // Get all audit events $events = AuditLogEvent::forReferenceId($referenceId)->get();
Request Tracing
Every HTTP request is assigned a unique reference_id (via the X-Lunnar-Reference-Id header). This ID links:
- The incoming HTTP request in
audit_log_requests - All outgoing HTTP requests in
audit_log_outgoing_requests - All audit events triggered during that request in
audit_log_events
This enables full traceability from a single request to all database changes and external API calls it caused.
// Find all activity during a specific request $referenceId = '550e8400-e29b-41d4-a716-446655440000'; $incomingRequest = AuditLogRequest::forReferenceId($referenceId)->first(); $outgoingRequests = AuditLogOutgoingRequest::forReferenceId($referenceId)->get(); $events = AuditLogEvent::forReferenceId($referenceId)->get(); // Or from an event, get the original request $event = AuditLogEvent::first(); $httpRequest = $event->request();
Verifying Checksum Integrity
use Lunnar\AuditLogging\Support\AuditChecksum; use Lunnar\AuditLogging\Models\AuditLogEvent; $event = AuditLogEvent::find($id); $isValid = AuditChecksum::verify([ 'event' => $event->event, 'message_data' => $event->message_data, 'payload' => $event->payload, 'diff' => $event->diff, 'actor_id' => $event->actor_id, 'subjects' => $event->subjects->map->only(['subject_type', 'subject_id', 'role'])->all(), 'level' => $event->level, ], $event->checksum);
Retention Policy
The package includes separate retention policies for audit log events, request logs, and outgoing request logs, allowing different retention periods for each.
Configuration
In config/audit-logging.php:
// Audit log events retention 'retention' => [ 'delete_after' => 365, // Delete events after 1 year 'schedule' => 'daily', // Automatically run daily at 3:00 AM ], // Request logs retention (can be shorter since request data is often less critical) 'request_log_retention' => [ 'delete_after' => 30, // Delete request logs after 30 days 'schedule' => 'daily', // Automatically run daily at 3:15 AM ], // Outgoing request logs retention 'outgoing_request_log_retention' => [ 'delete_after' => 30, // Delete outgoing request logs after 30 days 'schedule' => 'daily', // Automatically run daily at 3:30 AM ],
Options for each:
delete_after: Days until records are deleted. Set tonullto disable.schedule:'daily','weekly','monthly', ornullto disable automatic scheduling.
Running Manually
# Run all retention policies php artisan audit:retention # Only process audit log events php artisan audit:retention --events # Only process request logs php artisan audit:retention --requests # Only process outgoing request logs php artisan audit:retention --outgoing-requests
Config File Reference
Publish the config file to customize defaults:
php artisan vendor:publish --tag=audit-logging-config
All Options
| Option | Type | Default | Description |
|---|---|---|---|
audit_key |
string |
env('AUDIT_KEY') |
HMAC key for checksum integrity verification |
default_exclude |
array |
['id', 'created_at', ...] |
Fields excluded from audit payload by default |
default_ignore_changes |
array |
['updated_at'] |
Fields ignored when detecting changes |
sensitive_fields |
array |
['password', 'token', ...] |
Field patterns to redact (case-insensitive) |
actor_model |
string |
App\Models\User |
Model class for actor relationships |
default_level |
int |
0 |
Default event level when not explicitly specified |
request_logging.enabled |
bool |
true |
Enable/disable incoming request logging |
request_logging.only_authenticated |
bool |
false |
Only log requests from authenticated users |
request_logging.exclude_methods |
array |
['GET', 'HEAD', 'OPTIONS'] |
HTTP methods to exclude from logging |
outgoing_request_logging.enabled |
bool |
true |
Enable/disable outgoing request logging |
outgoing_request_logging.exclude_urls |
array |
[] |
URL patterns to exclude (supports * wildcards) |
retention.delete_after |
int|null |
null |
Days until audit events are deleted |
retention.schedule |
string|null |
null |
Auto-schedule: 'daily', 'weekly', 'monthly' |
request_log_retention.delete_after |
int|null |
null |
Days until request logs are deleted |
request_log_retention.schedule |
string|null |
null |
Auto-schedule: 'daily', 'weekly', 'monthly' |
outgoing_request_log_retention.delete_after |
int|null |
null |
Days until outgoing request logs are deleted |
outgoing_request_log_retention.schedule |
string|null |
null |
Auto-schedule: 'daily', 'weekly', 'monthly' |
License
MIT License. See LICENSE for details.