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' => '[email protected]',
    'password' => bcrypt('password123')
]);
// Trail record: event='created', changes=['name' => 'John Doe', 'email' => '[email protected]']

// 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' => '[email protected]',
    'password' => 'secret123',
    'api_token' => 'abc123xyz'
]);

// Stored in trail as:
// changes: {
//   "name": "John Doe",
//   "email": "[email protected]", 
//   "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', '[email protected]')")
    ]);

// 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.