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:
composer require spatie/laravel-activitylogMigration
Run migrations to create the activity_log table:
php artisan migrateConfiguration
Publish and customize the configuration:
php artisan vendor:publish --tag=activitylogCore PHP extends the default configuration:
// 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
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:
$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" eventManual Logging
Log custom activities:
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:
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:
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:
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
// 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
// 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:
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
activity()
->performedOn($post)
->withProperties([
'old_status' => 'draft',
'new_status' => 'published',
'scheduled_at' => $post->published_at,
'notified_subscribers' => true,
])
->log('published');Retrieving Properties
$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:
$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
// Controller
public function activityFeed()
{
$activities = Activity::with(['causer', 'subject'])
->forCurrentWorkspace()
->latest()
->paginate(20);
return view('activity-feed', compact('activities'));
}<!-- 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>
@endforeachCustom Descriptions
Make descriptions more readable:
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:
// 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
// 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:
// config/core.php
'activity' => [
'retention_days' => 90,
'cleanup_enabled' => true,
],Schedule the cleanup command:
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
$schedule->command('activity:prune')
->daily()
->at('02:00');
}Manual Pruning
# 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-runSelective Deletion
// 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:
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
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:
// config/activitylog.php
'activity_model' => App\Models\Activity::class,Queued Logging
Log activity in the background for performance:
// 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:
// config/core.php
'activity' => [
'log_ip_address' => false,
'anonymize_after_days' => 30,
],Anonymization
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:
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:
$activities = Activity::with(['causer', 'subject'])
->latest()
->paginate(20);Selective Logging
Only log important changes:
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
// Disable for bulk operations
activity()->disableLogging();
Post::query()->update(['migrated' => true]);
activity()->enableLogging();Testing
Testing Activity Logging
<?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
// ✅ 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
// ✅ 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
// ✅ 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');