I lean on Spatie Activitylog for audit trails. Paired with a feature-first, action-driven stack (Form Requests → Actions → Resources), it gives consistent, queryable events that are safe to expose.
Why Spatie Activitylog?
- Minimal boilerplate: traits on models or
activity()calls in Actions. - Flexible metadata:
propertiesJSON for device/IP/old/new values. - Built-in query API for feeds (search, date, subject/causer filtering).
Model-Level Logging (automatic)
class ForestSurvey extends Model
{
use HasFactory, LogsActivity;
protected $guarded = ['id'];
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logAll()
->useLogName('forest_survey')
->dontSubmitEmptyLogs();
}
}
Use when CRUD snapshots are enough.
Watchouts: exclude sensitive fields; log names help filter feeds.
Action-Level Logging (richer context)
class LeaveStoreAction
{
public function execute(LeaveStoreRequest $request): Leave
{
$leave = Leave::create([...]);
activity('leave')
->performedOn($leave)
->causedBy($request->user())
->withProperties([
'ip' => $request->ip(),
'ua' => $request->userAgent(),
'reason' => $request->input('reason'),
])
->event('created')
->log('Leave created');
return $leave;
}
}
Use when you need domain verbs (approve, cancel) or request/device metadata.
Structuring Log Metadata
Baseline keys I keep consistent:
ip,ua,device(or hashed fingerprint)actor_rolesnapshot (optional)old,newfor key fields on updates- domain context (
leave_type,status_before/after,amount) request_idoractivity_uidfor correlation
Consistency makes feeds easy to query and parse.
Building a Queryable Activity Feed
Controller outline
class ActivityLogController extends Controller
{
public function index(Request $request)
{
$query = Activity::with('causer')
->when($request->filled('event'), fn($q) => $q->where('event', $request->event))
->when($request->filled('log_name'), fn($q) => $q->where('log_name', $request->log_name))
->when($request->filled('causer_id'), fn($q) => $q->where('causer_id', $request->causer_id))
->when($request->filled('subject_type'), fn($q) => $q->where('subject_type', $request->subject_type))
->when($request->filled('date_from'), fn($q) => $q->whereDate('created_at', '>=', $request->date_from))
->when($request->filled('date_to'), fn($q) => $q->whereDate('created_at', '<=', $request->date_to))
->latest();
$activities = $query->paginate($request->get('per_page', 15));
return ActivityLogResource::collection($activities);
}
}
Resource
class ActivityLogResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'event' => $this->event,
'log_name' => $this->log_name,
'description'=> $this->description,
'actor' => $this->causer?->only(['id','name','email']),
'subject' => [
'type' => class_basename($this->subject_type),
'id' => $this->subject_id,
],
'properties' => $this->properties,
'created_at' => $this->created_at?->toISOString(),
];
}
}
Exposure & Security
- Strip secrets from
properties; never log tokens/passwords/PII you don’t need. - Gate feed endpoints via policies (admins see all; users see their own).
- Paginate with sensible caps; filter by date/event/log_name.
- Redact sensitive fields before responding.
Edge Cases & Hygiene
- Bulk updates: log a summary event instead of per-row.
- Idempotent actions: use request IDs to avoid double-logging.
- Timezones: store UTC; convert in Resources/UI.
- Backfills/migrations: tag
event = backfill.
Testing Checklist
- Logs created with correct
event,log_name,causer,subject. - Properties have expected keys; no sensitive data.
- Feed filters work: event/log_name/date/subject/causer.
- Pagination caps respected; unauthorized users get 403.
Performance Tips
- Index
log_name,event,causer_id,subject_type+subject_id,created_at. - Prune or archive old logs if volume is large.
- For chatty domains, batch-create logs in a queue job.
Takeaway
Define a consistent metadata schema, log at the Action level when you need context, and expose a filtered feed through Resources. Spatie Activitylog becomes a trustworthy audit trail for users and auditors alike.