Follow API
Following is a one-way, independent relationship that sits alongside connections. A follows B
without B doing anything, and following or unfollowing never changes connection_status in
either direction.
- Base URL:
/api/v1 - Auth: every endpoint here requires
Authorization: Bearer {token}(Sanctum). - Content type:
application/json
Response envelope (all endpoints):
// Success // Error
{ {
"status": "success", "status": "error",
"message": "ā¦", "message": "ā¦",
"data": { } "data": { }
} }
Switch-over: everything below is live. Flip the frontend constant:
// src/features/networks/constants/follow-api.ts
export const IS_FOLLOW_API_LIVE = true;
The rules that matter
1. Follow and connection are independent. Every combination of the two is reachable and valid: connected-and-following, connected-not-following, following-not-connected, neither. Never derive one from the other.
2. Follow and unfollow are idempotent. Following someone you already follow returns 200, not
422. Unfollowing someone you do not follow returns 200. Optimistic buttons can retry safely.
3. Connecting follows, but disconnecting does not unfollow.
| Action | Connection effect | Follow effect |
|---|---|---|
POST /feed/connections/{user_id} (send request) |
ā pending_outgoing |
Creates the follow, same transaction |
POST /feed/connections/{user_id}/accept |
ā connected |
None. Only the requester follows |
DELETE /feed/connections/{user_id} (remove/cancel) |
ā none |
None. The follow stands |
POST /feed/users/{user_id}/follow |
None | ā following |
DELETE /feed/users/{user_id}/follow |
None | ā not following |
POST /users/{user_id}/block |
Connection deleted | Follow deleted, both directions |
4. Follow is always immediate. There is no follow-request state and no private-profile gate. The UI's assumption is correct.
5. is_following is the viewer's state. It is false on your own profile and false for a
guest. The counts (followers_count, following_count) are public facts about the subject.
New endpoints
Follow a user
POST /api/v1/feed/users/{user_id}/follow
{user_id} is the user id, matching POST /feed/connections/{user_id} ā not the username.
200 OK
{
"status": "success",
"message": "Followed.",
"data": { "is_following": true, "followers_count": 1284 }
}
followers_count is the subject's count after the write, so the optimistic counter can be
reconciled against it.
| Status | When |
|---|---|
200 |
Followed, or already following (no-op) |
422 |
Following yourself, or either party has blocked the other |
404 |
No such user |
Unfollow a user
DELETE /api/v1/feed/users/{user_id}/follow
200 OK
{
"status": "success",
"message": "Unfollowed.",
"data": { "is_following": false, "followers_count": 1283 }
}
Returns 200 whether or not a follow existed. Unfollowing a connection leaves the connection
intact ā the header falls back to the connected state, not to "not connected".
Followers list
GET /api/v1/feed/users/{username}/followers?page=1&per_page=15
Keyed by username, matching the profile reads it sits beside. Newest follow first.
Following list
GET /api/v1/feed/users/{username}/following?page=1&per_page=15
Same shape and ordering. Both return the standard connection-user row, so each row carries its own Connect and Follow button:
{
"status": "success",
"message": "Followers retrieved.",
"data": {
"users": {
"data": [
{
"id": "9d1fā¦",
"name": "Sarah K.",
"username": "sarah.k",
"bio": "Product designer",
"avatar_url": "https://ā¦",
"connection_status": "none",
"is_connected": false,
"is_following": true,
"is_me": false
}
],
"links": { "first": "ā¦", "last": "ā¦", "prev": null, "next": null },
"meta": { "current_page": 1, "from": 1, "last_page": 1, "per_page": 15, "to": 1, "total": 1 }
}
}
}
Note ā these require auth. The request asked for public reads, but the entire
/feedprefix is behindauth:sanctum, and the siblingGET /feed/users/{username}/connectionsis auth-only too. Consistency won. Tell us if you need them genuinely public and we will move them out of the feed group.
Changes to existing endpoints
Every field below is additive. Nothing was renamed or removed, so shipping the frontend before or after this lands is safe either way.
GET /user/{username} ā three new fields
{
"data": {
"user": {
"connections_count": 128,
"posts_count": 42,
"followers_count": 1284, // NEW ā public
"following_count": 310, // NEW ā public
"connection_status": "connected",
"is_connected": true,
"is_following": true, // NEW ā the viewer's state; false on your own profile
"is_me": false
}
}
}
GET /feed/users/{username} ā the same three fields
followers_count, following_count and is_following, in the same positions.
POST / DELETE /feed/connections/{user_id} ā is_following added
All three connection mutations now report the resulting follow state so the UI never has to guess:
// POST /feed/connections/{user_id} ā 201
{ "connection_status": "pending_outgoing", "is_following": true }
// POST /feed/connections/{user_id}/accept ā 200
{ "connection_status": "connected", "is_following": false } // accepting does NOT follow
// DELETE /feed/connections/{user_id} ā 200
{ "connection_status": "none", "is_following": true } // removing does NOT unfollow
List surfaces ā is_following on every user row
Added to the shared connection-user object, so it appears on all of these at once:
| Endpoint | Object |
|---|---|
GET /feed/connections |
connection-user |
GET /feed/connections/pending |
connection-user |
GET /feed/connections/sent |
connection-user |
GET /feed/users/{username}/connections |
connection-user |
GET /feed/users/{username}/followers |
connection-user |
GET /feed/users/{username}/following |
connection-user |
GET /search (people results) |
connection-user + headline, role |
GET /feed/suggestions |
suggested-profile |
| Any endpoint returning posts | post.author |
Post authors carry it inside the existing author object:
"author": {
"id": "7d2eā¦",
"username": "john_doe",
"name": "John Doe",
"avatar_url": null,
"role": "mentor",
"connection_status": "connected",
"is_connected": true,
"is_following": false, // NEW
"is_me": false
}
Suggestions are worth one note: connection_status there is always "none" (the suggestion
query excludes anyone you have a connection edge with), but is_following is really computed ā
a follow does not disqualify a suggestion, so a suggested profile may well be someone you already
follow.
total_reviews on the mentor profile
mentor_profile now carries total_reviews next to average_rating:
"mentor_profile": {
"average_rating": "4.90",
"rating_count": 128, // pre-existing, unchanged
"total_reviews": 128, // NEW ā same number, the name you type it as
"total_sessions": 412
}
Served on both GET /user/{username} and GET /mentee/mentors. 0 for an unreviewed mentor,
never null. You can delete placeholderReviewCount() in
src/features/mentor/constants/mentor-card.constants.ts.
rating_countwas already being served under that name and other callers use it, so both ship. They are always the same number ā read whichever you prefer, and prefertotal_reviewsin new code.
One asymmetry to be aware of on the mentee dashboard's spotlight
(GET /mentee/dashboard/featured-mentor): average_rating stays null for an unreviewed
mentor while total_reviews reports 0. That is deliberate ā a null average is the signal you
already use to hide the star indicator, where 0.00 would render as a mentor nobody liked. A count
has no such misreading.
Notification
feed.new_follower is now emitted, on the existing notification channels ā it arrives live over the
websocket and lands in GET /notifications like any other type. Nothing further is needed on the
frontend for it to appear.
{
"type": "feed.new_follower",
"follower_id": "9d1fā¦",
"follower_name": "Sarah K.",
"follower_username": "sarah.k"
}
Fired only on a genuinely new follow. A retried optimistic press does not ring the bell twice.
Answers to the open questions
1. Is there an existing followers table? No ā this is new. The only prior trace was a migration
renaming a post-visibility enum, unrelated. The unique index on (follower_id, followed_id) was
added as suggested, which is what makes the no-op cases free.
2. Should blocking drop the follow both ways? Yes, and it does. Blocking already tore down the connection edge; it now deletes the follow in both directions. A one-way leftover would keep one party in the other's feed, which is a half-block.
3. Do private profiles need a follow request state? No. Follow is always immediate ā the Follow
record has no status column at all. The UI's assumption is correct.