Activity Log API
The authenticated user's chronological history of what they did — and what was done to them — across posts, comments, mentions, communities, courses, connections, sessions and payments.
Every endpoint requires auth:sanctum and is scoped to $request->user(). There is no {user}
segment and no admin variant: a token can only ever read or write its own history.
1. Design
Owner and actor are different columns
A row is addressed to an owner (user_id — whose log it appears in) and carries an actor
(actor_id — who performed it). For your own actions the two are equal. For a mention they are
not: the row lands in your log with the author as the actor, which is what lets one table answer
both "what did I do" and "what happened to me".
This is the one structural difference from notifications, which models an inbox of things done
to you and holds no record of what you did.
A new activity type is one enum case
activities stores what an activity points at polymorphically (subject, context) and what it
renders with as JSON (properties). Nothing about a new activity needs a column, so adding one is:
- a case on
App\Enums\Activity\ActivityType, with alabel()and acategory(); - one
$this->recordActivity(...)call at the write site.
tests/Unit/Activity/ActivityTypeTest.php fails the build if a case is added without a label or
without exactly one category, so the contract cannot be half-satisfied.
Rows are self-describing
properties carries the display payload captured at write time — the post excerpt, the
community slug, the counterparty's name. Posts and comments get deleted and courses get renamed;
a log that joined to live rows would render blanks or lie. It also keeps the list query free of
polymorphic N+1s. Same reasoning as notifications.data and admin_audit_logs.changes.
The one thing deliberately not denormalised is the actor's name and avatar. Those resolve live
through NotificationSenderResolver, so a user who changes their picture changes it everywhere,
on the activity log and the notification inbox alike.
Logging can never break the thing it describes
ActivityLogger catches its own exceptions, reports through log_entry() and returns null.
This is the opposite of AuditLogService, which must run inside its caller's transaction so a
failed audit rolls back the moderation action — an unlogged admin action is not permitted to exist.
Here the priority inverts: a missing history row is cosmetic, a post that fails to publish because
of one is not. Pinned by a test that takes the activities table away and asserts the post still
returns 201.
Key classes
| Piece | Path | Role |
|---|---|---|
ActivityType |
app/Enums/Activity/ActivityType.php |
The catalogue. String-backed domain.event values, first-person labels |
ActivityCategory |
app/Enums/Activity/ActivityCategory.php |
The page's tabs. types() is derived from ActivityType::category(), so membership has one source |
Activity |
app/Models/Activity/Activity.php |
The row. No updated_at; $dateFormat keeps milliseconds |
ActivityLogger |
app/Services/Activity/ActivityLogger.php |
The only write path. Swallows its own failures by design |
RecordsActivity |
app/Services/Activity/Concerns/RecordsActivity.php |
What services use. One line per hook, no constructor dependency |
ActivityFeedService |
app/Services/Activity/ActivityFeedService.php |
Read side. Every query starts from forUser() |
ActivityLinkBuilder |
app/Services/Activity/ActivityLinkBuilder.php |
Deep links; delegates to NotificationLinkBuilder for routes it already owns |
RecordBookingActivity |
app/Listeners/Activity/RecordBookingActivity.php |
Booking activity, off the events the booking domain already emits |
2. The catalogue
Tab (category) |
Types |
|---|---|
posts |
post.created · post.shared · post.reacted |
comments |
comment.created · comment.replied · comment.reacted |
mentions |
mention.received |
communities |
community.created · community.joined · community.join_requested · community.left · community.post_created · community.comment_created · community.level_up |
courses |
course.created · course.published · course.enrolled · course.lesson_completed · course.quiz_passed · course.assignment_submitted · course.completed · course.reviewed |
connections |
connection.requested · connection.accepted · connection.removed |
sessions |
session.booked · session.confirmed · session.completed · session.cancelled |
payments |
payment.completed · payment.refunded |
account |
mentor.registered · mentor.approved |
mention.received is one case for all four mentionable surfaces; properties.surface and
subject.type say which. Adding a fifth surface needs no new case.
Deliberately absent: received reactions and post views. Both are high-volume, already covered by the notification inbox, and would bury the activity that matters. Each is one case away.
⚠️ There is no follow/unfollow in this application. Migration
2026_07_15_000001_rename_followers_visibility_to_connectionsreplaced follows with symmetric connections. Theconnectionstab is the follow-equivalent:connection.removedis the only record a disconnect leaves anywhere, becauseConnectionService::remove()hard-deletes the edge and it carries no status trail.
3. Idempotency
Some activities are re-runnable and must still read as one thing the user did. Those are written
through a dedupe_key, claimed with insertOrIgnore against unique(user_id, dedupe_key) — the
same concurrency primitive NotificationDispatcher uses.
| Activity | Deduped on | Because |
|---|---|---|
post.reacted · comment.reacted |
the target | Switching like → celebrate → like is one reaction |
course.lesson_completed · course.quiz_passed |
enrolment + lesson | Un-ticking and re-ticking is not a second completion |
course.completed |
the enrolment | The course:recalculate-enrollment-progress sweep lands in the same code path |
course.enrolled · payment.* |
the enrolment / payment | Stripe redelivers webhooks |
session.* |
booking + lifecycle step | The expiry sweep and a retry can reach the same transition |
course.reviewed |
the review | A review is an upsert; revising a rating is the same opinion |
course.published · mentor.* |
the subject | Unpublishing and republishing is one publication |
course.assignment_submitted is deliberately not deduped — a resubmission is a real second
attempt and the learner's history should show both.
4. Endpoints
GET /api/v1/activities
Newest first. All filters optional and combinable.
| Parameter | Example | Notes |
|---|---|---|
category |
?category=courses |
One tab. 422 on an unknown value |
type |
?type=post.created,post.shared or ?type[]=post.created |
Comma list or array. Intersects with category |
from / to |
?from=2026-08-01&to=2026-08-31 |
Inclusive; to covers the whole day. 422 if from is later than to |
per_page |
?per_page=20 |
1–100, default config('constants.pagination.default') = 15 |
{
"status": "success",
"message": "Activities retrieved successfully.",
"data": {
"activities": {
"data": [
{
"id": "9b1f…",
"type": "mention.received",
"type_label": "Was mentioned",
"category": "mentions",
"category_label": "Mentions",
"actor": {
"id": "8a2c…",
"username": "sara",
"full_name": "Sara Ali",
"avatar_url": "https://…/sara.jpg",
"is_self": false
},
"subject": { "type": "feed_post", "id": "7c3d…", "title": "Hey @you look at this" },
"context": null,
"properties": { "post_id": "7c3d…", "surface": "feed_post", "subject_title": "Hey @you look at this" },
"url": "https://app.meetyy.test/feed/7c3d…",
"created_at": "2026-09-02T10:11:12+00:00"
}
],
"links": { "first": "…", "last": "…", "prev": null, "next": null },
"meta": { "current_page": 1, "from": 1, "last_page": 1, "per_page": 15, "to": 1, "total": 1 }
}
}
}
subject.type and context.type are stable short aliases (feed_post, community,
course_lesson, …), never PHP class names — the column stores FQCNs because this application
registers no morph map, but that is storage, not contract. actor is null for platform-driven
rows; url is null where there is nowhere useful to send the reader.
GET /api/v1/activities/summary
Per-tab counts for the tab badges. Every category answers, including empty ones, so the client can render the row without knowing the catalogue.
{
"status": "success",
"message": "Activity summary retrieved successfully.",
"data": {
"summary": [
{ "category": "posts", "label": "Posts", "count": 12 },
{ "category": "comments", "label": "Comments", "count": 4 },
{ "category": "sessions", "label": "Sessions", "count": 0 }
],
"total": 16
}
}
DELETE /api/v1/activities/{id}
Removes one row from the caller's own log. 404 for a row belonging to anyone else — the
service scopes through forUser() before it looks the row up, so an id from another user's log is
indistinguishable from one that does not exist.
DELETE /api/v1/activities
Clears the caller's log. ?category=posts clears one tab. Answers with how many rows went:
{ "status": "success", "message": "Activity log cleared.", "data": { "deleted": 12 } }
The log is the user's own history, not evidence about them, which is why it has a delete path at
all — admin_audit_logs, which is evidence, has none.
5. Retention
php artisan activities:prune deletes rows past config('constants.activity.retention_months')
(default 12), scheduled monthly in routes/console.php beside notifications:prune-dispatches.
Deletes in batches of 1000 so a platform-wide sweep does not hold locks long enough to be felt by
everyone writing activity at that moment. --months= overrides the window for a one-off.
6. Adding an activity
- Add the
ActivityTypecase, itslabel()and itscategory(). use RecordsActivityin the service that owns the write, if it does not already.- Call
$this->recordActivity($user, ActivityType::YourCase, subject: …, properties: […])— after the enclosingDB::transaction()where the method allows it, since the row is not worth risking the action over. - Add a case to
tests/Feature/Activity/ActivityRecordingTest.phpdriving the real endpoint.
No migration. If the activity can fire twice for the same fact, pass a dedupeKey.
Why the call is explicit rather than a model observer. Three blockers, not preferences:
ConnectionService::remove() deletes through the query builder, which fires no model events at
all; the mentions pivot has no model to hook; and the paths that settle enrolments, issue
refunds and complete sessions run in queued jobs and the scheduler, where auth()->user() is null
and the actor would be lost. Half the catalogue would need explicit calls regardless, and two
mechanisms means "why didn't this log?" is answered in two places. The RecordsActivity trait
removes the plumbing without the implicitness.