Skip to content

Trail Package ​

Trail is a comprehensive audit logging and activity tracking system for Laravel applications. It automatically captures model changes, request context, and user activities, providing a complete audit trail for your application's data modifications.

Features Overview ​

🔍 Comprehensive Audit Logging ​

  • Model Event Tracking: Automatically monitors created, updated, deleted, restored, and force-deleted events
  • Change Detection: Captures detailed before/after attribute changes with intelligent diffing
  • Snapshot Support: Optionally stores complete model snapshots for point-in-time recovery
  • Request Context: Records IP address, user agent, HTTP method, URL, and request ID

🚀 Performance & Scalability ​

  • Queue Integration: Optional background processing to prevent blocking operations
  • Smart Caching: Efficient data structure for minimal database impact
  • Bulk Operations: Chunked processing for large datasets
  • Automatic Pruning: Built-in retention management with configurable policies

🛡️ Security & Privacy ​

  • Sensitive Data Masking: Automatically redacts passwords, tokens, and secrets
  • Configurable Attributes: Flexible control over which attributes to track or ignore
  • User Association: Links activities to authenticated users across multiple guards
  • Request Correlation: Groups related activities using unique request identifiers

🔧 Developer Experience ​

  • Simple Integration: Single trait adds full audit logging to any Eloquent model
  • Flexible Configuration: Extensive configuration options for different use cases
  • Filament Integration: Built-in admin panel resources for Twist framework users
  • Manual Logging: Programmatic API for custom events and activities

Installation ​

Install the Trail package via Composer:

bash
composer require obelaw/trail

Configuration & Setup ​

  1. Publish configuration and migrations:
bash
php artisan vendor:publish --tag=trail-config
php artisan vendor:publish --tag=trail-migrations
  1. Run the migrations:
bash
php artisan migrate
  1. Configure the package (optional):

Edit config/trail.php to customize behavior according to your needs.

Database Schema ​

The Trail package creates a comprehensive trails table with the following structure:

php
Schema::create('trails', function (Blueprint $table) {
    $table->id();
    
    // Model relationships
    $table->nullableMorphs('causer');      // User who caused the change
    $table->morphs('trailable');           // Model being tracked
    
    // Event details
    $table->string('event');               // Event type (created, updated, etc.)
    $table->json('changes')->nullable();   // Attribute changes
    $table->json('snapshot')->nullable();  // Full model snapshot
    
    // Request context
    $table->string('ip_address', 45)->nullable();
    $table->string('user_agent', 512)->nullable();
    $table->string('method', 16)->nullable();
    $table->string('url', 1024)->nullable();
    $table->string('guard', 64)->nullable();
    $table->uuid('request_id')->nullable();
    
    $table->timestamps();
    
    // Indexes for performance
    $table->index(['trailable_type', 'trailable_id']);
    $table->index(['causer_type', 'causer_id']);
    $table->index('request_id');
});

Basic Usage ​

Adding Trail to Your Models ​

The simplest way to enable audit logging is by adding the TracksTrail trait to your Eloquent models:

php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Obelaw\Trail\Traits\TracksTrail;

class User extends Model
{
    use TracksTrail;

    protected $fillable = [
        'name', 'email', 'password'
    ];
}

Automatic Event Tracking ​

Once the trait is added, Trail automatically captures all model events:

php
// Creating a new user
$user = User::create([
    'name' => 'John Doe',
    'email' => 'john@example.com',
    'password' => bcrypt('password123')
]);
// Trail record: event='created', changes=['name' => 'John Doe', 'email' => 'john@example.com']

// Updating the user
$user->update(['name' => 'John Smith']);
// Trail record: event='updated', changes=['name' => ['old' => 'John Doe', 'new' => 'John Smith']]

// Deleting the user
$user->delete();
// Trail record: event='deleted', changes=[...], snapshot=[complete user data]

Alternative: Configuration-Based Watching ​

Instead of adding traits to each model, you can configure models to watch globally:

php
// config/trail.php
return [
    'watch' => [
        App\Models\User::class,
        App\Models\Order::class,
        App\Models\Product::class,
    ],
    // ... other config
];

Manual Logging ​

Using the Trail Facade ​

For custom events or manual logging:

php
use Obelaw\Trail\Facades\Trail;

// Log a custom event
Trail::for($user)
    ->event('login_attempt')
    ->changes(['ip' => request()->ip(), 'success' => true])
    ->save();

// Log with causer (who performed the action)
Trail::for($order)
    ->by($admin)
    ->event('status_changed')
    ->changes([
        'status' => ['old' => 'pending', 'new' => 'confirmed'],
        'reason' => 'Manual approval by admin'
    ])
    ->snapshot($order->toArray())
    ->save();

// Log application-level events
Trail::event('system_maintenance')
    ->changes([
        'action' => 'backup_started',
        'duration_estimate' => '30 minutes'
    ])
    ->save();

Using Helper Functions ​

Trail provides convenient helper functions for quick logging:

php
// Quick event logging
trail_event('user_export', [
    'format' => 'csv',
    'record_count' => 1500,
    'exported_by' => auth()->id()
]);

// Get current causer (authenticated user)
$user = trail_causer(); // Returns auth()->user() safely

Manual Trail Builder ​

For more complex scenarios, use the Trail builder directly:

php
use Obelaw\Trail\Recording\TrailBuilder;

$trail = TrailBuilder::make()
    ->for($product)
    ->by($user)
    ->event('price_changed')
    ->changes([
        'price' => ['old' => 99.99, 'new' => 89.99],
        'discount_applied' => true
    ])
    ->props([
        'promotion_id' => 123,
        'change_reason' => 'Black Friday sale'
    ])
    ->context([
        'source' => 'admin_panel',
        'batch_id' => 'batch_001'
    ])
    ->save();

Querying Trail Records ​

Basic Queries ​

Trail provides convenient methods for querying audit logs:

php
use Obelaw\Trail\Models\Trail;

// Get all trails for a specific model
$userTrails = Trail::forTrailed(User::class, $user->id)->get();

// Filter by event type
$createdRecords = Trail::event('created')->get();

// Get trails by causer (who performed actions)
$adminActions = Trail::forCauser(Admin::class, $admin->id)->get();

// Combine filters
$userUpdates = Trail::forTrailed(User::class, $user->id)
    ->event('updated')
    ->where('created_at', '>=', now()->subDays(30))
    ->orderBy('created_at', 'desc')
    ->get();

Advanced Queries ​

php
// Find all password changes in the last week
$passwordChanges = Trail::whereJsonContains('changes', ['password'])
    ->where('created_at', '>=', now()->subWeek())
    ->get();

// Get all actions from a specific IP address
$ipActions = Trail::where('ip_address', '192.168.1.100')
    ->with(['trailable', 'causer'])
    ->get();

// Find all failed login attempts
$failedLogins = Trail::event('login_attempt')
    ->whereJsonPath('changes->success', false)
    ->get();

// Group activities by request ID
$requestActivities = Trail::where('request_id', $requestId)
    ->orderBy('created_at')
    ->get();

// Find bulk operations
$bulkOperations = Trail::select('request_id')
    ->groupBy('request_id')
    ->havingRaw('COUNT(*) > 10')
    ->get();

Working with Relationships ​

php
// Get trail with related models
$trail = Trail::with(['trailable', 'causer'])->find($id);

echo "User {$trail->causer->name} {$trail->event} {$trail->trailable->name}";

// Access model relationships
if ($trail->trailable instanceof User) {
    $user = $trail->trailable;
    echo "Modified user: {$user->email}";
}

// Find all trails for models created by specific user
$createdByUser = Trail::whereHas('causer', function ($query) use ($userId) {
    $query->where('id', $userId);
})->event('created')->get();

Configuration Options ​

Complete Configuration Reference ​

php
// config/trail.php
return [
    // Database connection (null uses default)
    'connection' => null,

    // Table name for trail records
    'table' => 'trails',

    // Which model events to capture
    'events' => [
        'created', 
        'updated', 
        'deleted', 
        'restored', 
        'forceDeleted'
    ],

    // Attributes to ignore when computing changes
    'ignore_attributes' => [
        'updated_at',
        'remember_token',
        'last_login_at'
    ],

    // Attributes to redact/mask in logs
    'redact_attributes' => [
        'password',
        'password_confirmation',
        'token',
        'secret',
        'api_key',
        'private_key'
    ],

    // Store complete model snapshot
    'store_snapshot' => true,

    // Use queue for non-blocking writes
    'queue' => false,

    // Fire TrailRecorded event after saving
    'broadcast' => false,

    // Retention period in days
    'retention_days' => 365,

    // Models to auto-watch (alternative to trait)
    'watch' => [
        // App\Models\User::class,
        // App\Models\Order::class,
    ],
];

Event-Specific Configuration ​

You can customize which events to track per model:

php
class User extends Model
{
    use TracksTrail;

    // Override tracked events for this model
    protected static function bootTracksTrail(): void
    {
        static::observe(new TrailObserver(['created', 'updated']));
    }
}

Custom Attribute Handling ​

php
class Product extends Model
{
    use TracksTrail;

    // Custom attributes to ignore for this model
    public function getTrailIgnoreAttributes(): array
    {
        return array_merge(
            config('trail.ignore_attributes', []),
            ['view_count', 'last_viewed_at']
        );
    }

    // Custom attributes to redact
    public function getTrailRedactAttributes(): array
    {
        return ['internal_cost', 'supplier_price'];
    }
}

Queue Integration ​

Enabling Queue Support ​

For high-traffic applications, enable queue processing to prevent blocking operations:

php
// config/trail.php
return [
    'queue' => true,
    // ... other config
];

Custom Queue Configuration ​

php
// In your service provider
use Obelaw\Trail\Jobs\StoreTrail;

// Configure queue connection and settings
StoreTrail::dispatch($payload)->onQueue('audit-logs');

// Use delayed processing
StoreTrail::dispatch($payload)->delay(now()->addSeconds(30));

Processing Trail Jobs ​

Ensure your queue worker is running:

bash
# Process trail jobs
php artisan queue:work --queue=default,audit-logs

# Monitor queue status
php artisan queue:monitor

Data Retention & Pruning ​

Automatic Pruning ​

Trail includes a built-in pruning command to manage storage:

bash
# Prune records older than configured retention period
php artisan trail:prune

# Prune with custom retention period
php artisan trail:prune --days=180

# Prune only specific events
php artisan trail:prune --days=90 --events=created,updated

Scheduled Pruning ​

Add pruning to your application's scheduler:

php
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
    // Daily pruning using config retention
    $schedule->command('trail:prune')->daily();

    // Weekly pruning with custom retention
    $schedule->command('trail:prune --days=365')->weekly();

    // Monthly deep cleanup
    $schedule->command('trail:prune --days=90')->monthly();
}

Manual Pruning with Queries ​

For custom pruning logic:

php
use Obelaw\Trail\Models\Trail;
use Carbon\Carbon;

// Delete trails older than 2 years
Trail::where('created_at', '<', Carbon::now()->subYears(2))->delete();

// Keep only latest 1000 records per model type
Trail::where('trailable_type', User::class)
    ->orderBy('created_at', 'desc')
    ->skip(1000)
    ->delete();

// Prune based on event type
Trail::whereIn('event', ['created', 'updated'])
    ->where('created_at', '<', Carbon::now()->subDays(30))
    ->delete();

Security & Privacy ​

Sensitive Data Protection ​

Trail automatically protects sensitive information:

php
// These attributes are masked by default
$user = User::create([
    'name' => 'John Doe',
    'email' => 'john@example.com',
    'password' => 'secret123',
    'api_token' => 'abc123xyz'
]);

// Stored in trail as:
// changes: {
//   "name": "John Doe",
//   "email": "john@example.com", 
//   "password": "*****",
//   "api_token": "*****"
// }

Custom Data Masking ​

php
// Add custom sensitive fields
// config/trail.php
return [
    'redact_attributes' => [
        'password', 'token', 'secret',
        'ssn', 'credit_card', 'bank_account',
        'private_key', 'encryption_key'
    ],
];

// Model-specific masking
class PaymentMethod extends Model
{
    use TracksTrail;

    public function getTrailRedactAttributes(): array
    {
        return ['card_number', 'cvv', 'account_number'];
    }
}

GDPR Compliance ​

For GDPR and privacy compliance:

php
// Remove all trails for a user (right to be forgotten)
Trail::forTrailed(User::class, $user->id)->delete();

// Or anonymize instead of delete
Trail::forTrailed(User::class, $user->id)
    ->update([
        'ip_address' => null,
        'user_agent' => 'anonymized',
        'changes' => DB::raw("JSON_SET(changes, '$.email', 'anonymized@example.com')")
    ]);

// Export user's trail data (right to access)
$userTrails = Trail::forTrailed(User::class, $user->id)
    ->orWhere(function ($query) use ($user) {
        $query->forCauser(User::class, $user->id);
    })
    ->get()
    ->toJson();

Integration Examples ​

E-commerce Order Tracking ​

php
class Order extends Model
{
    use TracksTrail;
}

class OrderService
{
    public function updateOrderStatus(Order $order, string $status, User $user)
    {
        $oldStatus = $order->status;
        
        $order->update(['status' => $status]);
        
        // Manual trail for business context
        Trail::for($order)
            ->by($user)
            ->event('status_changed')
            ->changes([
                'old_status' => $oldStatus,
                'new_status' => $status,
                'changed_by' => $user->name,
                'change_reason' => request('reason')
            ])
            ->save();
    }
}

// Query order history
$orderHistory = Trail::forTrailed(Order::class, $order->id)
    ->orderBy('created_at')
    ->get()
    ->map(function ($trail) {
        return [
            'event' => $trail->event,
            'changes' => $trail->changes,
            'user' => $trail->causer?->name,
            'date' => $trail->created_at,
            'ip' => $trail->ip_address
        ];
    });

User Management System ​

php
class User extends Model
{
    use TracksTrail;
}

class UserController extends Controller
{
    public function updateProfile(Request $request, User $user)
    {
        $changes = $request->only(['name', 'email', 'phone']);
        
        // Update user
        $user->update($changes);
        
        // Additional context for profile changes
        Trail::for($user)
            ->by(auth()->user())
            ->event('profile_updated')
            ->changes($changes)
            ->props([
                'updated_fields' => array_keys($changes),
                'source' => 'web_interface'
            ])
            ->save();
    }

    public function viewAuditLog(User $user)
    {
        $auditLog = Trail::forTrailed(User::class, $user->id)
            ->with('causer')
            ->orderBy('created_at', 'desc')
            ->paginate(25);

        return view('admin.users.audit', compact('user', 'auditLog'));
    }
}

Content Management System ​

php
class Article extends Model
{
    use TracksTrail;
}

class ArticleObserver
{
    public function updated(Article $article)
    {
        // Additional business logic for article updates
        if ($article->isDirty('status')) {
            Trail::for($article)
                ->by(auth()->user())
                ->event('status_change')
                ->changes([
                    'old_status' => $article->getOriginal('status'),
                    'new_status' => $article->status,
                    'visibility' => $article->status === 'published' ? 'public' : 'private'
                ])
                ->save();
        }
    }
}

Filament Integration ​

For Twist framework users, Trail provides built-in Filament resources:

Admin Panel Resources ​

The Trail package automatically registers Filament resources when used with the Twist framework:

php
// Automatically available in Filament Admin
// - Trail Resource for viewing audit logs
// - Trail relation managers for models
// - Trail widgets for dashboard insights

Custom Trail Resource ​

Create custom Filament resources for your trail data:

php
use Obelaw\Trail\Models\Trail;
use Filament\Resources\Resource;
use Filament\Tables\Table;
use Filament\Tables\Columns\TextColumn;

class TrailResource extends Resource
{
    protected static ?string $model = Trail::class;

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                TextColumn::make('trailable_type')->label('Model'),
                TextColumn::make('event')->badge(),
                TextColumn::make('causer.name')->label('User'),
                TextColumn::make('ip_address'),
                TextColumn::make('created_at')->dateTime(),
            ])
            ->filters([
                SelectFilter::make('event')
                    ->options([
                        'created' => 'Created',
                        'updated' => 'Updated', 
                        'deleted' => 'Deleted',
                    ]),
            ]);
    }
}

API Reference ​

Trail Model Methods ​

scopeForTrailed($query, string $type, $id) ​

Filter trails for a specific model:

php
Trail::forTrailed(User::class, $userId)->get();

scopeForCauser($query, string $type, $id) ​

Filter trails by who caused them:

php
Trail::forCauser(Admin::class, $adminId)->get();

scopeEvent($query, string $event) ​

Filter by event type:

php
Trail::event('updated')->get();

Trail Builder Methods ​

for(?Model $model): self ​

Set the model being tracked:

php
Trail::for($user)->event('login')->save();

by(?Model $user): self ​

Set who caused the action:

php
Trail::for($order)->by($admin)->event('approved')->save();

event(string $event): self ​

Set the event type:

php
Trail::event('custom_action')->changes($data)->save();

changes(array $changes): self ​

Set the change data:

php
Trail::changes(['status' => ['old' => 'pending', 'new' => 'active']])->save();

snapshot(?array $snapshot): self ​

Set model snapshot:

php
Trail::snapshot($model->toArray())->save();

props(array $properties): self ​

Set additional properties:

php
Trail::props(['source' => 'api', 'batch_id' => 123])->save();

context(array $context): self ​

Set request context:

php
Trail::context(['feature_flag' => 'enabled'])->save();

Helper Functions ​

trail_event(string $type, array $properties = [], $model = null) ​

Quick event logging:

php
trail_event('backup_completed', ['size' => '2.4GB']);

trail_causer(): ?Authenticatable ​

Get current authenticated user safely:

php
$user = trail_causer(); // Returns auth()->user() or null

Events & Broadcasting ​

TrailRecorded Event ​

Trail fires a TrailRecorded event after saving each record:

php
// Listen for trail events
Event::listen(TrailRecorded::class, function ($event) {
    $trail = $event->trail;
    
    // Send to external systems
    Http::post('https://audit-system.com/webhook', [
        'event' => $trail->event,
        'model' => $trail->trailable_type,
        'user' => $trail->causer?->email,
        'timestamp' => $trail->created_at
    ]);
});

Real-time Notifications ​

php
// Send real-time notifications for critical changes
Event::listen(TrailRecorded::class, function (TrailRecorded $event) {
    if ($event->trail->trailable_type === User::class && 
        $event->trail->event === 'deleted') {
        
        Notification::route('slack', config('slack.audit_channel'))
            ->notify(new UserDeletedNotification($event->trail));
    }
});

Performance Optimization ​

Database Indexing ​

Add custom indexes for your query patterns:

php
// Migration for custom indexes
Schema::table('trails', function (Blueprint $table) {
    // Index for event type queries
    $table->index(['event', 'created_at']);
    
    // Index for date range queries  
    $table->index(['trailable_type', 'created_at']);
    
    // Index for IP-based queries
    $table->index(['ip_address', 'created_at']);
});

Query Optimization ​

php
// Use eager loading for relationships
$trails = Trail::with(['trailable', 'causer'])
    ->forTrailed(User::class, $userId)
    ->get();

// Use select() to limit columns
$summaryTrails = Trail::select(['event', 'created_at', 'causer_id'])
    ->event('login')
    ->get();

// Use chunking for large datasets
Trail::where('created_at', '<', $cutoffDate)
    ->chunkById(1000, function ($trails) {
        foreach ($trails as $trail) {
            // Process each trail
        }
    });

Memory Management ​

php
// Process large datasets efficiently
Trail::forTrailed(User::class, $userId)
    ->lazy()
    ->each(function (Trail $trail) {
        // Process one at a time to save memory
        $this->processTrail($trail);
    });

Best Practices ​

1. Model Configuration ​

  • Use the TracksTrail trait for automatic tracking
  • Configure ignore_attributes to exclude timestamp fields
  • Set up redact_attributes for sensitive data protection

2. Performance Considerations ​

  • Enable queuing for high-traffic applications
  • Implement regular pruning to manage storage
  • Add appropriate database indexes for your query patterns
  • Use eager loading when accessing relationships

3. Privacy & Security ​

  • Always redact sensitive information
  • Implement data retention policies
  • Consider GDPR compliance requirements
  • Use IP anonymization for privacy protection

4. Monitoring & Alerting ​

  • Set up alerts for unusual activity patterns
  • Monitor trail storage growth
  • Track queue processing performance
  • Alert on failed audit log writes

Troubleshooting ​

Common Issues ​

Trails Not Being Created ​

  1. Verify the trait is added to your model:
php
class YourModel extends Model
{
    use TracksTrail; // Make sure this is present
}
  1. Check if the model is in the ignore list
  2. Verify middleware is registered for request context

Performance Issues ​

  1. Enable queue processing:
php
// config/trail.php
'queue' => true,
  1. Add appropriate indexes:
php
Schema::table('trails', function (Blueprint $table) {
    $table->index(['trailable_type', 'trailable_id']);
});

Storage Growing Too Fast ​

  1. Implement regular pruning:
php
$schedule->command('trail:prune --days=90')->daily();
  1. Reduce tracked events:
php
// config/trail.php
'events' => ['created', 'updated'], // Remove 'deleted', etc.

Queue Jobs Failing ​

  1. Verify queue worker is running:
bash
php artisan queue:work
  1. Check for serialization issues with complex models
  2. Monitor queue failure logs

Contributing ​

We welcome contributions to the Trail package! Please see our contributing guidelines for details.

License ​

The Trail package is open-source software licensed under the MIT license.

Released under the MIT License.