2026-01-26 16:59:47 +00:00
|
|
|
# Activity Logging
|
|
|
|
|
|
|
|
|
|
Track user actions, model changes, and system events with GDPR-compliant activity logging.
|
|
|
|
|
|
|
|
|
|
## Basic Usage
|
|
|
|
|
|
|
|
|
|
### Enabling Activity Logging
|
|
|
|
|
|
|
|
|
|
Add the `LogsActivity` trait to your model:
|
|
|
|
|
|
|
|
|
|
```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', 'status'];
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**Automatic Logging:**
|
|
|
|
|
- Created events
|
|
|
|
|
- Updated events (with changed attributes)
|
|
|
|
|
- Deleted events
|
|
|
|
|
- Restored events (soft deletes)
|
|
|
|
|
|
|
|
|
|
### Manual Logging
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
use Core\Activity\Services\ActivityLogService;
|
|
|
|
|
|
|
|
|
|
$logger = app(ActivityLogService::class);
|
|
|
|
|
|
|
|
|
|
// Log custom activity
|
|
|
|
|
$logger->log(
|
|
|
|
|
subject: $post,
|
|
|
|
|
event: 'published',
|
|
|
|
|
description: 'Post published to homepage',
|
|
|
|
|
causer: auth()->user()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Log with properties
|
|
|
|
|
$logger->log(
|
|
|
|
|
subject: $post,
|
|
|
|
|
event: 'viewed',
|
|
|
|
|
properties: [
|
|
|
|
|
'ip_address' => request()->ip(),
|
|
|
|
|
'user_agent' => request()->userAgent(),
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Activity Model
|
|
|
|
|
|
|
|
|
|
### Retrieving Activity
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
use Core\Activity\Models\Activity;
|
|
|
|
|
|
|
|
|
|
// Get all activity
|
|
|
|
|
$activities = Activity::latest()->get();
|
|
|
|
|
|
|
|
|
|
// Get activity for specific model
|
|
|
|
|
$postActivity = Activity::forSubject($post)->get();
|
|
|
|
|
|
|
|
|
|
// Get activity by user
|
|
|
|
|
$userActivity = Activity::causedBy($user)->get();
|
|
|
|
|
|
|
|
|
|
// Get activity by event
|
|
|
|
|
$published = Activity::where('event', 'published')->get();
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Activity Attributes
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
$activity = Activity::latest()->first();
|
|
|
|
|
|
|
|
|
|
$activity->subject; // The model that was acted upon
|
|
|
|
|
$activity->causer; // The user who caused the activity
|
|
|
|
|
$activity->event; // Event name (created, updated, deleted, etc.)
|
|
|
|
|
$activity->description; // Human-readable description
|
|
|
|
|
$activity->properties; // Additional data (array)
|
|
|
|
|
$activity->created_at; // When it occurred
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Relationships
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
// Subject (polymorphic)
|
|
|
|
|
$post = $activity->subject;
|
|
|
|
|
|
|
|
|
|
// Causer (polymorphic)
|
|
|
|
|
$user = $activity->causer;
|
|
|
|
|
|
|
|
|
|
// Workspace (if applicable)
|
|
|
|
|
$workspace = $activity->workspace;
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Activity Scopes
|
|
|
|
|
|
|
|
|
|
### Filtering Activity
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
use Core\Activity\Models\Activity;
|
|
|
|
|
|
|
|
|
|
// By date range
|
|
|
|
|
$activities = Activity::query()
|
|
|
|
|
->whereBetween('created_at', [now()->subDays(7), now()])
|
|
|
|
|
->get();
|
|
|
|
|
|
|
|
|
|
// By event type
|
|
|
|
|
$activities = Activity::query()
|
|
|
|
|
->whereIn('event', ['created', 'updated'])
|
|
|
|
|
->get();
|
|
|
|
|
|
|
|
|
|
// By workspace
|
|
|
|
|
$activities = Activity::query()
|
|
|
|
|
->where('workspace_id', $workspace->id)
|
|
|
|
|
->get();
|
|
|
|
|
|
|
|
|
|
// Complex filters
|
|
|
|
|
$activities = Activity::query()
|
|
|
|
|
->forSubject($post)
|
|
|
|
|
->causedBy($user)
|
|
|
|
|
->where('event', 'updated')
|
|
|
|
|
->latest()
|
|
|
|
|
->paginate(20);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Custom Scopes
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
use Core\Activity\Scopes\ActivityScopes;
|
|
|
|
|
|
|
|
|
|
// Add to Activity model
|
|
|
|
|
class Activity extends Model
|
|
|
|
|
{
|
|
|
|
|
use ActivityScopes;
|
|
|
|
|
|
|
|
|
|
public function scopeForWorkspace($query, $workspaceId)
|
|
|
|
|
{
|
|
|
|
|
return $query->where('workspace_id', $workspaceId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function scopeWithinDays($query, $days)
|
|
|
|
|
{
|
|
|
|
|
return $query->where('created_at', '>=', now()->subDays($days));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Usage
|
|
|
|
|
$recent = Activity::withinDays(7)
|
|
|
|
|
->forWorkspace($workspace->id)
|
|
|
|
|
->get();
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Customizing Logged Data
|
|
|
|
|
|
|
|
|
|
### Controlling What's Logged
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
class Post extends Model
|
|
|
|
|
{
|
|
|
|
|
use LogsActivity;
|
|
|
|
|
|
|
|
|
|
// Only log these events
|
|
|
|
|
protected static $recordEvents = ['created', 'published'];
|
|
|
|
|
|
|
|
|
|
// Exclude these attributes from change tracking
|
|
|
|
|
protected static $ignoreChangedAttributes = ['views', 'updated_at'];
|
|
|
|
|
|
|
|
|
|
// Log only these attributes
|
|
|
|
|
protected static $logAttributes = ['title', 'status'];
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Custom Descriptions
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
class Post extends Model
|
|
|
|
|
{
|
|
|
|
|
use LogsActivity;
|
|
|
|
|
|
|
|
|
|
public function getActivityDescription(string $event): string
|
|
|
|
|
{
|
|
|
|
|
return match($event) {
|
|
|
|
|
'created' => "Created post: {$this->title}",
|
|
|
|
|
'updated' => "Updated post: {$this->title}",
|
|
|
|
|
'published' => "Published post: {$this->title}",
|
|
|
|
|
default => "Post {$event}",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Custom Properties
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
class Post extends Model
|
|
|
|
|
{
|
|
|
|
|
use LogsActivity;
|
|
|
|
|
|
|
|
|
|
public function getActivityProperties(string $event): array
|
|
|
|
|
{
|
|
|
|
|
return [
|
|
|
|
|
'title' => $this->title,
|
|
|
|
|
'category' => $this->category->name,
|
|
|
|
|
'word_count' => str_word_count($this->content),
|
|
|
|
|
'published_at' => $this->published_at?->toIso8601String(),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## GDPR Compliance
|
|
|
|
|
|
|
|
|
|
### IP Address Hashing
|
|
|
|
|
|
|
|
|
|
IP addresses are automatically hashed for privacy:
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
use Core\Crypt\LthnHash;
|
|
|
|
|
|
|
|
|
|
// Automatically applied
|
|
|
|
|
$activity = Activity::create([
|
|
|
|
|
'properties' => [
|
|
|
|
|
'ip_address' => request()->ip(), // Hashed before storage
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Verify IP match without storing plaintext
|
|
|
|
|
if (LthnHash::check(request()->ip(), $activity->properties['ip_address'])) {
|
|
|
|
|
// IP matches
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Data Retention
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
use Core\Activity\Console\ActivityPruneCommand;
|
|
|
|
|
|
|
|
|
|
// Prune old activity (default: 90 days)
|
|
|
|
|
php artisan activity:prune
|
|
|
|
|
|
|
|
|
|
// Custom retention
|
|
|
|
|
php artisan activity:prune --days=30
|
|
|
|
|
|
|
|
|
|
// Dry run
|
|
|
|
|
php artisan activity:prune --dry-run
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**Scheduled Pruning:**
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
// app/Console/Kernel.php
|
|
|
|
|
protected function schedule(Schedule $schedule)
|
|
|
|
|
{
|
|
|
|
|
$schedule->command('activity:prune')
|
|
|
|
|
->daily()
|
|
|
|
|
->at('02:00');
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Right to Erasure
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
// Delete all activity for a user
|
|
|
|
|
Activity::causedBy($user)->delete();
|
|
|
|
|
|
|
|
|
|
// Delete activity for specific subject
|
|
|
|
|
Activity::forSubject($post)->delete();
|
|
|
|
|
|
|
|
|
|
// Anonymize instead of delete
|
|
|
|
|
Activity::causedBy($user)->update([
|
|
|
|
|
'causer_id' => null,
|
|
|
|
|
'causer_type' => null,
|
|
|
|
|
]);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Activity Feed
|
|
|
|
|
|
|
|
|
|
### Building Activity Feeds
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
use Core\Activity\Models\Activity;
|
|
|
|
|
|
|
|
|
|
// User's personal feed
|
|
|
|
|
$feed = Activity::causedBy($user)
|
|
|
|
|
->with(['subject', 'causer'])
|
|
|
|
|
->latest()
|
|
|
|
|
->paginate(20);
|
|
|
|
|
|
|
|
|
|
// Workspace activity feed
|
|
|
|
|
$feed = Activity::query()
|
|
|
|
|
->where('workspace_id', $workspace->id)
|
|
|
|
|
->whereIn('event', ['created', 'updated', 'published'])
|
|
|
|
|
->with(['subject', 'causer'])
|
|
|
|
|
->latest()
|
|
|
|
|
->paginate(20);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Rendering Activity
|
|
|
|
|
|
|
|
|
|
```blade
|
|
|
|
|
{{-- resources/views/activity/feed.blade.php --}}
|
|
|
|
|
@foreach($activities as $activity)
|
|
|
|
|
<div class="activity-item">
|
|
|
|
|
<div class="activity-icon">
|
|
|
|
|
@if($activity->event === 'created')
|
|
|
|
|
<svg>...</svg>
|
|
|
|
|
@elseif($activity->event === 'updated')
|
|
|
|
|
<svg>...</svg>
|
|
|
|
|
@endif
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="activity-content">
|
|
|
|
|
<p>
|
|
|
|
|
<strong>{{ $activity->causer?->name ?? 'System' }}</strong>
|
|
|
|
|
{{ $activity->description }}
|
|
|
|
|
</p>
|
|
|
|
|
<time>{{ $activity->created_at->diffForHumans() }}</time>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
@endforeach
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Livewire Component
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace Core\Activity\View\Modal\Admin;
|
|
|
|
|
|
|
|
|
|
use Livewire\Component;
|
|
|
|
|
use Core\Activity\Models\Activity;
|
|
|
|
|
|
|
|
|
|
class ActivityFeed extends Component
|
|
|
|
|
{
|
|
|
|
|
public $workspaceId;
|
|
|
|
|
public $events = ['created', 'updated', 'deleted'];
|
|
|
|
|
public $days = 7;
|
|
|
|
|
|
|
|
|
|
public function render()
|
|
|
|
|
{
|
|
|
|
|
$activities = Activity::query()
|
|
|
|
|
->when($this->workspaceId, fn($q) => $q->where('workspace_id', $this->workspaceId))
|
|
|
|
|
->whereIn('event', $this->events)
|
|
|
|
|
->where('created_at', '>=', now()->subDays($this->days))
|
|
|
|
|
->with(['subject', 'causer'])
|
|
|
|
|
->latest()
|
|
|
|
|
->paginate(20);
|
|
|
|
|
|
|
|
|
|
return view('activity::admin.activity-feed', [
|
|
|
|
|
'activities' => $activities,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Performance Optimization
|
|
|
|
|
|
|
|
|
|
### Eager Loading
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
// ✅ Good - eager load relationships
|
|
|
|
|
$activities = Activity::query()
|
|
|
|
|
->with(['subject', 'causer', 'workspace'])
|
|
|
|
|
->latest()
|
|
|
|
|
->get();
|
|
|
|
|
|
|
|
|
|
// ❌ Bad - N+1 queries
|
|
|
|
|
$activities = Activity::latest()->get();
|
|
|
|
|
foreach ($activities as $activity) {
|
|
|
|
|
echo $activity->causer->name; // Query per iteration
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Chunking Large Datasets
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
// Process activity in chunks
|
|
|
|
|
Activity::query()
|
|
|
|
|
->where('created_at', '<', now()->subDays(90))
|
|
|
|
|
->chunk(1000, function ($activities) {
|
|
|
|
|
foreach ($activities as $activity) {
|
|
|
|
|
$activity->delete();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Queuing Activity Logging
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
// For high-traffic applications
|
|
|
|
|
use Illuminate\Bus\Queueable;
|
|
|
|
|
|
|
|
|
|
class Post extends Model
|
|
|
|
|
{
|
|
|
|
|
use LogsActivity;
|
|
|
|
|
|
|
|
|
|
protected static $logActivityQueue = true;
|
|
|
|
|
|
|
|
|
|
protected static $logActivityConnection = 'redis';
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Analytics
|
|
|
|
|
|
|
|
|
|
### Activity Statistics
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
use Core\Activity\Services\ActivityLogService;
|
|
|
|
|
|
|
|
|
|
$analytics = app(ActivityLogService::class);
|
|
|
|
|
|
|
|
|
|
// Count by event type
|
|
|
|
|
$stats = Activity::query()
|
|
|
|
|
->where('workspace_id', $workspace->id)
|
|
|
|
|
->whereBetween('created_at', [now()->subDays(30), now()])
|
|
|
|
|
->groupBy('event')
|
|
|
|
|
->selectRaw('event, COUNT(*) as count')
|
|
|
|
|
->get();
|
|
|
|
|
|
|
|
|
|
// Most active users
|
|
|
|
|
$topUsers = Activity::query()
|
|
|
|
|
->selectRaw('causer_id, causer_type, COUNT(*) as activity_count')
|
|
|
|
|
->groupBy('causer_id', 'causer_type')
|
|
|
|
|
->orderByDesc('activity_count')
|
|
|
|
|
->limit(10)
|
|
|
|
|
->get();
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Audit Reports
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
// Generate audit trail
|
|
|
|
|
$audit = Activity::query()
|
|
|
|
|
->forSubject($post)
|
|
|
|
|
->with('causer')
|
|
|
|
|
->oldest()
|
|
|
|
|
->get()
|
|
|
|
|
->map(fn($activity) => [
|
|
|
|
|
'timestamp' => $activity->created_at->toIso8601String(),
|
|
|
|
|
'user' => $activity->causer?->name ?? 'System',
|
|
|
|
|
'event' => $activity->event,
|
|
|
|
|
'changes' => $activity->properties,
|
|
|
|
|
]);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Best Practices
|
|
|
|
|
|
|
|
|
|
### 1. Log Meaningful Events
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
// ✅ Good - business-relevant events
|
|
|
|
|
$logger->log($post, 'published', 'Post went live');
|
|
|
|
|
$logger->log($order, 'payment_received', 'Customer paid');
|
|
|
|
|
|
|
|
|
|
// ❌ Bad - too granular
|
|
|
|
|
$logger->log($post, 'view_count_incremented', 'Views++');
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 2. Include Context
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
// ✅ Good - rich context
|
|
|
|
|
$logger->log($post, 'published', properties: [
|
|
|
|
|
'category' => $post->category->name,
|
|
|
|
|
'scheduled' => $post->published_at->isPast(),
|
|
|
|
|
'author' => $post->author->name,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// ❌ Bad - no context
|
|
|
|
|
$logger->log($post, 'published');
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 3. Respect Privacy
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
// ✅ Good - hash sensitive data
|
|
|
|
|
$logger->log($user, 'login', properties: [
|
|
|
|
|
'ip_address' => LthnHash::make(request()->ip()),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// ❌ Bad - plaintext IP
|
|
|
|
|
$logger->log($user, 'login', properties: [
|
|
|
|
|
'ip_address' => request()->ip(),
|
|
|
|
|
]);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Testing
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
use Tests\TestCase;
|
|
|
|
|
use Core\Activity\Models\Activity;
|
|
|
|
|
|
|
|
|
|
class ActivityTest extends TestCase
|
|
|
|
|
{
|
|
|
|
|
public function test_logs_model_creation(): void
|
|
|
|
|
{
|
|
|
|
|
$post = Post::create(['title' => 'Test']);
|
|
|
|
|
|
|
|
|
|
$this->assertDatabaseHas('activities', [
|
|
|
|
|
'subject_type' => Post::class,
|
|
|
|
|
'subject_id' => $post->id,
|
|
|
|
|
'event' => 'created',
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function test_logs_changes(): void
|
|
|
|
|
{
|
|
|
|
|
$post = Post::factory()->create(['status' => 'draft']);
|
|
|
|
|
|
|
|
|
|
$post->update(['status' => 'published']);
|
|
|
|
|
|
|
|
|
|
$activity = Activity::latest()->first();
|
|
|
|
|
$this->assertEquals('published', $activity->properties['status']);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Learn More
|
|
|
|
|
|
2026-01-29 10:47:50 +00:00
|
|
|
- [Multi-Tenancy →](/core/tenancy)
|
2026-01-26 16:59:47 +00:00
|
|
|
- [GDPR Compliance →](/security/overview)
|