Skip to content

Activity Logging

Core PHP Framework provides comprehensive activity logging to track changes to your models and user actions. Built on Spatie's laravel-activitylog, it adds workspace-scoped logging and automatic cleanup.

Overview

Activity logging helps you:

  • Track who changed what and when
  • Maintain audit trails for compliance
  • Debug issues by reviewing historical changes
  • Display activity feeds to users
  • Revert changes when needed

Setup

Installation

The activity log package is included in Core PHP:

bash
composer require spatie/laravel-activitylog

Migration

Run migrations to create the activity_log table:

bash
php artisan migrate

Configuration

Publish and customize the configuration:

bash
php artisan vendor:publish --tag=activitylog

Core PHP extends the default configuration:

php
// config/core.php
'activity' => [
    'enabled' => env('ACTIVITY_LOG_ENABLED', true),
    'retention_days' => env('ACTIVITY_RETENTION_DAYS', 90),
    'cleanup_enabled' => true,
    'log_ip_address' => false, // GDPR compliance
],

Basic Usage

Adding Logging to Models

Use the LogsActivity trait:

php
<?php

namespace Mod\Blog\Models;

use Illuminate\Database\Eloquent\Model;
use Core\Activity\Concerns\LogsActivity;

class Post extends Model
{
    use LogsActivity;

    protected $fillable = ['title', 'content', 'published_at'];

    // Specify which attributes to log
    protected array $activityLogAttributes = ['title', 'content', 'published_at'];

    // Optionally, log all fillable attributes
    // protected static $logFillable = true;
}

Automatic Logging

Changes are logged automatically:

php
$post = Post::create([
    'title' => 'My First Post',
    'content' => 'Hello world!',
]);
// Activity logged: "created" event

$post->update(['title' => 'Updated Title']);
// Activity logged: "updated" event with changes

$post->delete();
// Activity logged: "deleted" event

Manual Logging

Log custom activities:

php
activity()
    ->performedOn($post)
    ->causedBy(auth()->user())
    ->withProperties(['custom' => 'data'])
    ->log('published');

// Or use the helper on the model
$post->logActivity('published', ['published_at' => now()]);

Configuration Options

Log Attributes

Specify which attributes to track:

php
class Post extends Model
{
    use LogsActivity;

    // Log specific attributes
    protected array $activityLogAttributes = ['title', 'content', 'status'];

    // Log all fillable attributes
    protected static $logFillable = true;

    // Log all attributes
    protected static $logAttributes = ['*'];

    // Log only dirty (changed) attributes
    protected static $logOnlyDirty = true;

    // Don't log these attributes
    protected static $logAttributesToIgnore = ['updated_at', 'view_count'];
}

Log Events

Control which events trigger logging:

php
class Post extends Model
{
    use LogsActivity;

    // Log only these events (default: all)
    protected static $recordEvents = ['created', 'updated', 'deleted'];

    // Don't log these events
    protected static $ignoreEvents = ['retrieved'];
}

Custom Log Names

Organize activities by type:

php
class Post extends Model
{
    use LogsActivity;

    public function getActivitylogOptions(): LogOptions
    {
        return LogOptions::defaults()
            ->logOnly(['title', 'content'])
            ->logOnlyDirty()
            ->setDescriptionForEvent(fn(string $eventName) => "Post {$eventName}")
            ->useLogName('blog');
    }
}

Retrieving Activity

Get All Activity

php
// All activity in the system
$activities = Activity::all();

// Recent activity
$recent = Activity::latest()->limit(10)->get();

// Activity for specific model
$postActivity = Activity::forSubject($post)->get();

// Activity by specific user
$userActivity = Activity::causedBy($user)->get();

Filtering Activity

php
// By log name
$blogActivity = Activity::inLog('blog')->get();

// By description
$publishedPosts = Activity::where('description', 'published')->get();

// By date range
$recentActivity = Activity::whereBetween('created_at', [
    now()->subDays(7),
    now(),
])->get();

// By properties
$activity = Activity::whereJsonContains('properties->status', 'published')->get();

Activity Scopes

Core PHP adds workspace scoping:

php
use Core\Activity\Scopes\ActivityScopes;

// Activity for current workspace
$workspaceActivity = Activity::forCurrentWorkspace()->get();

// Activity for specific workspace
$activity = Activity::forWorkspace($workspace)->get();

// Activity for specific subject type
$postActivity = Activity::forSubjectType(Post::class)->get();

Activity Properties

Storing Extra Data

php
activity()
    ->performedOn($post)
    ->withProperties([
        'old_status' => 'draft',
        'new_status' => 'published',
        'scheduled_at' => $post->published_at,
        'notified_subscribers' => true,
    ])
    ->log('published');

Retrieving Properties

php
$activity = Activity::latest()->first();

$properties = $activity->properties;
$oldStatus = $activity->properties['old_status'] ?? null;

// Access as object
$newStatus = $activity->properties->new_status;

Changes Tracking

View before/after values:

php
$post->update(['title' => 'New Title']);

$activity = Activity::forSubject($post)->latest()->first();

$changes = $activity->changes();
// [
//     'attributes' => ['title' => 'New Title'],
//     'old' => ['title' => 'Old Title']
// ]

Activity Presentation

Display Activity Feed

php
// Controller
public function activityFeed()
{
    $activities = Activity::with(['causer', 'subject'])
        ->forCurrentWorkspace()
        ->latest()
        ->paginate(20);

    return view('activity-feed', compact('activities'));
}
blade
<!-- View -->
@foreach($activities as $activity)
    <div class="activity-item">
        <div class="activity-icon">
            @if($activity->description === 'created')
                <span class="text-green-500">+</span>
            @elseif($activity->description === 'deleted')
                <span class="text-red-500">×</span>
            @else
                <span class="text-blue-500">•</span>
            @endif
        </div>

        <div class="activity-content">
            <p>
                <strong>{{ $activity->causer->name ?? 'System' }}</strong>
                {{ $activity->description }}
                <em>{{ class_basename($activity->subject_type) }}</em>
                @if($activity->subject)
                    <a href="{{ route('posts.show', $activity->subject) }}">
                        {{ $activity->subject->title }}
                    </a>
                @endif
            </p>
            <time>{{ $activity->created_at->diffForHumans() }}</time>
        </div>
    </div>
@endforeach

Custom Descriptions

Make descriptions more readable:

php
class Post extends Model
{
    use LogsActivity;

    public function getActivitylogOptions(): LogOptions
    {
        return LogOptions::defaults()
            ->setDescriptionForEvent(function(string $eventName) {
                return match($eventName) {
                    'created' => 'created post "' . $this->title . '"',
                    'updated' => 'updated post "' . $this->title . '"',
                    'deleted' => 'deleted post "' . $this->title . '"',
                    'published' => 'published post "' . $this->title . '"',
                    default => $eventName . ' post',
                };
            });
    }
}

Workspace Isolation

Automatic Scoping

Activity is automatically scoped to workspaces:

php
// Only returns activity for current workspace
$activity = Activity::forCurrentWorkspace()->get();

// Explicitly query another workspace (admin only)
if (auth()->user()->isSuperAdmin()) {
    $activity = Activity::forWorkspace($otherWorkspace)->get();
}

Cross-Workspace Activity

php
// Admin reports across all workspaces
$systemActivity = Activity::withoutGlobalScopes()->get();

// Activity counts by workspace
$stats = Activity::withoutGlobalScopes()
    ->select('workspace_id', DB::raw('count(*) as count'))
    ->groupBy('workspace_id')
    ->get();

Activity Cleanup

Automatic Pruning

Configure automatic cleanup of old activity:

php
// config/core.php
'activity' => [
    'retention_days' => 90,
    'cleanup_enabled' => true,
],

Schedule the cleanup command:

php
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
    $schedule->command('activity:prune')
        ->daily()
        ->at('02:00');
}

Manual Pruning

bash
# Delete activity older than configured retention period
php artisan activity:prune

# Delete activity older than specific number of days
php artisan activity:prune --days=30

# Dry run (see what would be deleted)
php artisan activity:prune --dry-run

Selective Deletion

php
// Delete activity for specific model
Activity::forSubject($post)->delete();

// Delete activity by log name
Activity::inLog('temporary')->delete();

// Delete activity older than date
Activity::where('created_at', '<', now()->subMonths(6))->delete();

Advanced Usage

Batch Logging

Log multiple changes as a single activity:

php
activity()->enableLogging();

// Disable automatic logging temporarily
activity()->disableLogging();

Post::create([/*...*/]); // Not logged
Post::create([/*...*/]); // Not logged
Post::create([/*...*/]); // Not logged

// Re-enable and log batch operation
activity()->enableLogging();

activity()
    ->performedOn($workspace)
    ->log('imported 100 posts');

Custom Activity Models

Extend the activity model:

php
<?php

namespace App\Models;

use Spatie\Activitylog\Models\Activity as BaseActivity;

class Activity extends BaseActivity
{
    public function scopePublic($query)
    {
        return $query->where('properties->public', true);
    }

    public function wasSuccessful(): bool
    {
        return $this->properties['success'] ?? true;
    }
}

Update config:

php
// config/activitylog.php
'activity_model' => App\Models\Activity::class,

Queued Logging

Log activity in the background for performance:

php
// In a job or listener
dispatch(function () use ($post, $user) {
    activity()
        ->performedOn($post)
        ->causedBy($user)
        ->log('processed');
})->afterResponse();

GDPR Compliance

Anonymize User Data

Don't log personally identifiable information:

php
// config/core.php
'activity' => [
    'log_ip_address' => false,
    'anonymize_after_days' => 30,
],

Anonymization

php
class AnonymizeOldActivity
{
    public function handle(): void
    {
        Activity::where('created_at', '<', now()->subDays(30))
            ->whereNotNull('causer_id')
            ->update([
                'causer_id' => null,
                'causer_type' => null,
                'properties->ip_address' => null,
            ]);
    }
}

User Data Deletion

Delete user's activity when account is deleted:

php
class User extends Model
{
    protected static function booted()
    {
        static::deleting(function ($user) {
            // Delete or anonymize activity
            Activity::causedBy($user)->delete();
        });
    }
}

Performance Optimization

Eager Loading

Prevent N+1 queries:

php
$activities = Activity::with(['causer', 'subject'])
    ->latest()
    ->paginate(20);

Selective Logging

Only log important changes:

php
class Post extends Model
{
    use LogsActivity;

    // Only log changes to these critical fields
    protected array $activityLogAttributes = ['title', 'published_at', 'status'];

    // Only log when attributes actually change
    protected static $logOnlyDirty = true;
}

Disable Logging Temporarily

php
// Disable for bulk operations
activity()->disableLogging();

Post::query()->update(['migrated' => true]);

activity()->enableLogging();

Testing

Testing Activity Logging

php
<?php

namespace Tests\Feature;

use Tests\TestCase;
use Mod\Blog\Models\Post;
use Spatie\Activitylog\Models\Activity;

class PostActivityTest extends TestCase
{
    public function test_logs_post_creation(): void
    {
        $post = Post::create([
            'title' => 'Test Post',
            'content' => 'Test content',
        ]);

        $activity = Activity::forSubject($post)->first();

        $this->assertEquals('created', $activity->description);
        $this->assertEquals(auth()->id(), $activity->causer_id);
    }

    public function test_logs_attribute_changes(): void
    {
        $post = Post::factory()->create(['title' => 'Original']);

        $post->update(['title' => 'Updated']);

        $activity = Activity::forSubject($post)->latest()->first();

        $this->assertEquals('updated', $activity->description);
        $this->assertEquals('Original', $activity->changes()['old']['title']);
        $this->assertEquals('Updated', $activity->changes()['attributes']['title']);
    }
}

Best Practices

1. Log Business Events

php
// ✅ Good - meaningful business events
$post->logActivity('published', ['published_at' => now()]);
$post->logActivity('featured', ['featured_until' => $date]);

// ❌ Bad - technical implementation details
$post->logActivity('database_updated');

2. Include Context

php
// ✅ Good - rich context
activity()
    ->performedOn($post)
    ->withProperties([
        'published_at' => $post->published_at,
        'notification_sent' => true,
        'subscribers_count' => $subscribersCount,
    ])
    ->log('published');

// ❌ Bad - minimal context
activity()->performedOn($post)->log('published');

3. Use Descriptive Log Names

php
// ✅ Good - organized by domain
activity()->useLog('blog')->log('post published');
activity()->useLog('commerce')->log('order placed');

// ❌ Bad - generic log name
activity()->useLog('default')->log('thing happened');

Learn More

Released under the EUPL-1.2 License.