php-framework/docs/packages/php/patterns/activity-logging.md

679 lines
14 KiB
Markdown
Raw Normal View History

# 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
- [Activity Feed UI](/packages/admin#activity-feed)
- [GDPR Compliance](/security/gdpr)
- [Testing Activity](/testing/activity-logging)