Public Feed API
Endpoints for the public feed — a global, role-agnostic social layer where any
authenticated user (mentor or mentee) can post, react, comment, share/repost, run polls,
connect with other users, and report content. Unlike the mentor-owned Community feed (which is
membership-gated and scoped to a community_id), the public feed is a single cross-platform
timeline built on a mutual connection graph.
- Base URL:
/api/v1 - Auth: every endpoint requires a Sanctum bearer token —
Authorization: Bearer {token}. There is no public (unauthenticated) surface. - Content type:
application/json(image uploads usemultipart/form-data; videos are not posted inline — they go through the resumable Chunked Upload API first, and the post then references the returnedvideo_upload_id)
Surface. All endpoints live under a single
feedprefix (/api/v1/feed/...) guarded byauth:sanctum, shared by every authenticated user regardless of role. Authorship is keyed byuser_id; ownership actions (edit/delete) are enforced in the service layer (→ 403).
Scope note. Reporting is capture-only in this cut — reports are persisted (polymorphic over posts and comments) with a
pendingstatus, but there is no admin moderation surface (list/resolve/hide) yet. Community post/comment reports flow into the samefeed_reportstable (see community-api.md §15a), so one future moderation surface covers both feeds. Reaction and comment notifications write synchronously to the database channel (no queue worker required).
Video note. A post carries either images (
attachments[]) or a single video (video_upload_id) — never both, and never a video alongside a poll. Videos are capped at 500 MB (VIDEO_MAX_SIZE) and limited tovideo/mp4,video/quicktime,video/webm. Thumbnails are generated client-side, not on the server. The frontend extracts a frame from the selected video (e.g.<video>+canvas) and uploads it as thethumbnailfield when creating the post; the server just stores it and echoes it back asvideo.thumbnail. If the client sends no thumbnail,video.thumbnailisnulland the player should fall back to the video's own first frame.
Response envelope
Success { "status": "success", "message": "…", "data": { } }
Error { "status": "error", "message": "…", "data": { } }
Validation errors (422) return field errors in data. Paginated lists are returned as a
Laravel resource-collection payload under a named key (posts, comments, users) with the
standard data / links / meta structure.
Status codes
| Code | Meaning |
|---|---|
| 200 | OK (read, reaction, vote, accept/remove connection) |
| 201 | Resource created (post, share, comment, report) |
| 401 | Missing/invalid token |
| 403 | Authenticated but not the author (edit/delete post or comment) |
| 404 | Post / comment / user not found |
| 409 | Content already reported by this user |
| 422 | Validation error / invalid state (self-connect, already connected, poll rules, expired poll) |
| 500 | Server error |
Enums
Post type: text · poll · share · video
Post visibility: public · connections
Sort order (feed sort query): top (engagement-ranked, default) · recent (chronological, newest first)
Reaction type: like · love · laugh · wow · sad · angry
Connection status (relative to the caller): none · pending_outgoing (caller requested them) · pending_incoming (they requested the caller) · connected
Report reason: spam · harassment · hate_speech · violence · nudity · misinformation · other
Report status: pending · reviewed · dismissed · actioned (capture-only — stays pending)
Notification types: feed.connection_request · feed.connection_accepted · feed.post_commented · feed.post_reacted
Shared objects
Post object
{
"id": "3c4d…",
"type": "text",
"type_label": "Text",
"visibility": "public",
"visibility_label": "Public",
"body": "Hello world",
"reaction_count": 4,
"comment_count": 2,
"share_count": 1,
"my_reaction": "love",
"reactions": [
{ "type": "love", "count": 3 },
{ "type": "like", "count": 1 }
],
"image_urls": [],
"video": {
"url": "https://…/clip.mp4",
"thumbnail": "https://…/clip-thumb.jpg",
"mime": "video/mp4"
},
"hashtags": ["laravel", "php"],
"author": {
"id": "7d2e…",
"username": "john_doe",
"name": "John Doe",
"avatar_url": null,
"role": "mentor",
"connection_status": "connected",
"is_connected": true,
"is_me": false
},
"poll": null,
"shared_post": null,
"comments": [ { "…comment object…" } ],
"created_at": "…",
"updated_at": "…"
}
my_reactionis the caller's own reaction type (ornull).reactionsis the per-type breakdown — an array of{ type, count }ordered by count descending, present on every post response (feed, search, show, create/update/share), and[]when there are no reactions;reaction_countremains the total.commentsis included only on the single-post (show) response.hashtagsis the list of tag names auto-parsed frombody(lowercased, deduplicated) — see the Hashtags section.pollis present fortype: poll;shared_post(a nested post object) fortype: share. Onauthor,roleis the author's platform role ("mentor","mentee","admin", ornullwhen they have none),connection_statusis the caller's relationship to the post's author (see the Connection status enum),is_connectedis a shorthand forconnection_status == "connected", andis_meistruewhen the caller is the author.
Poll object (post.poll)
{
"id": "9a0b…",
"question": "Best language?",
"allows_multiple": false,
"expires_at": null,
"is_closed": false,
"total_votes": 12,
"options": [
{ "id": "…", "label": "PHP", "position": 0, "vote_count": 7, "voted_by_me": true },
{ "id": "…", "label": "JS", "position": 1, "vote_count": 5, "voted_by_me": false }
]
}
is_closed is true once expires_at has passed. voted_by_me reflects the caller's vote(s).
Comment object
{
"id": "5e6f…",
"post_id": "3c4d…",
"parent_id": null,
"body": "Nice one",
"reaction_count": 1,
"my_reaction": "like",
"my_reaction_exists": true,
"reactions": [ { "type": "like", "count": 1 } ],
"author": { "id": "7d2e…", "name": "John Doe", "avatar_url": null },
"replies": [ { "…comment object…" } ],
"replies_count": 12,
"created_at": "…",
"updated_at": "…"
}
Comments carry the same reaction shape as posts — reaction_count, my_reaction, and the
per-type reactions breakdown. my_reaction_exists is retained as a convenience boolean
(my_reaction !== null).
Comments support one-level threading via parent_id. replies holds at most the first 3
replies (oldest first) as a preview so a thread renders in one round-trip; replies_count is the
full total. When replies_count exceeds the nested array, page the remainder via the replies
endpoint. Both keys are omitted on reply objects and wherever replies are not loaded.
Connection-user object
{ "id": "7d2e…", "name": "John Doe", "username": "john_doe", "avatar_url": null, "connection_status": "connected", "is_connected": true }
connection_status is the caller's relationship to this listed user (see the Connection
status enum); is_connected is a shorthand for connection_status == "connected".
Hashtag object
{ "name": "laravel", "posts_count": 42, "created_at": "…" }
The tag's normalized name (lowercase, no leading #) — also its route key. posts_count is the
number of posts carrying the tag, included on the popular hashtags list and the browse-by-tag
response (omitted where not counted).
Profile object
{
"id": "7d2e…",
"username": "john_doe",
"name": "John Doe",
"bio": "Building things.",
"avatar_url": null,
"cover_photo_url": null,
"connections_count": 128,
"posts_count": 17,
"connection_status": "connected",
"is_connected": true,
"is_me": false
}
connection_status reflects the caller's relationship to this user (see the Connection
status enum), is_connected is a shorthand for connection_status == "connected", and is_me
is true when the profile belongs to the caller.
Feed
A single unified feed replaces the previously separate home/discover feeds. It defaults to an
engagement-ranked order (sort=top) and accepts sort=recent for the classic chronological
order.
Query params
| Param | Type | Default | Notes |
|---|---|---|---|
sort |
enum | top |
top (engagement-ranked) · recent (newest first). Invalid value → 422 |
per_page |
int | 15 | 1–50 |
1. Unified feed
GET /api/v1/feed → paginated unified feed
GET /api/v1/feed?sort=recent → same set, newest first
Returns, in one ranked stream:
- your own posts,
- every post by your connections (including their
connections-only posts), and - all public posts across the platform.
Under sort=top, popular public posts naturally surface alongside the connection graph via the
per-post hot_score. A stranger's connections-only post is never shown. Each post carries
my_reaction, live counters, and an author object with connection_status / is_me.
The separate
GET /api/v1/feed/discoverendpoint has been removed — its content is now part ofGET /api/v1/feed.
Ranking ("hot" score)
sort=top orders by a persisted per-post hot_score — weighted engagement over a time-decay
denominator, so posts that draw reactions/comments/shares rise while older posts fade:
hot_score = (reaction_count + 2·comment_count + 3·share_count)
/ POW(hoursSinceCreated + 2, 1.5)
The score is refreshed live on each reaction/comment/share and by a scheduled sweep, and ties
break on created_at DESC (stable pagination). Weights, gravity and the recompute window are
configurable server-side (config/feed.php) — clients only choose sort.
2. Search
GET /api/v1/feed/searchno longer exists. Post search now lives in the global search endpoint, alongside people, communities and courses.
GET /api/v1/search?q=laravel → all four tabs, 5 rows each
GET /api/v1/search?q=laravel&tab=post → posts only, 20 rows
GET /api/v1/search?q=laravel&tab=post&sort=recent
GET /api/v1/search?h=laravel → posts carrying #laravel (implies tab=post)
GET /api/v1/search?h=%23laravel → same (a leading '#' is ignored)
Two mutually exclusive modes: q matches free text, h matches a hashtag exactly. Sending both
is a 422; sending neither is a 422. Results are capped lists — never paginated, and
results is always an object keyed by tab.
| Param | Type | Required | Notes |
|---|---|---|---|
q |
string | one of | Free-text term; 1–100 chars |
h |
string | one of | Hashtag, with or without #; 1–100 chars. Post tab only |
tab |
enum | no | post · user · community · course. Omit in q mode to get all four |
limit |
int | no | Rows per tab; 5 without tab, 20 with one. Max 50 |
sort |
enum | no | Post tab only: top (engagement-ranked, default) · recent |
scope |
enum | no | Course & community tabs: all (default) · created · member · public |
The post tab returns the same post object as the timeline and enforces the same visibility
rules: the caller's own posts and their connections' posts (any visibility) plus all public
posts — a stranger's connections-only post never appears. In q mode a post matches on its body
only; use h to match on the tag itself.
The course and community tabs cover everything the caller may see — published/public rows plus
the ones they authored or joined/enrolled in, drafts included — each flagged with is_creator and
is_enrolled / member_role. scope narrows that to a single arm.
Posts
3. Create a post
POST /api/v1/feed/posts (multipart when sending images) → 201 { "data": { "post": { … } } }
A post is text, a poll when a poll object is present, or a video when a
video_upload_id is present.
| Field | Type | Required | Notes |
|---|---|---|---|
body |
string | conditionally | Required unless poll or video_upload_id is present; ≤ 10000 |
visibility |
enum | no | public (default) · connections |
attachments[] |
file | no | ≤ 10 images, ≤ 10 MB each |
video_upload_id |
uuid | no | A completed feed_video chunked upload (see upload-api.md). Mutually exclusive with attachments and poll. |
thumbnail |
file | no | Client-generated video thumbnail (image, ≤ 5 MB). Only meaningful with video_upload_id. |
poll |
object | no | Presence makes the post a poll |
poll.question |
string | no | ≤ 255 |
poll.allows_multiple |
bool | no | Default false |
poll.expires_at |
date | no | Must be in the future |
poll.options |
array | with poll |
2–10 entries |
poll.options.* |
string | yes | ≤ 255 |
Video posts. Upload the file first via the Chunked Upload API (purpose
feed_video), then create the post with the returnedvideo_upload_id. The response carries avideoobject (url,thumbnail,mime); the key is omitted on non-video posts.Thumbnail. Generate it on the client and send it as the
thumbnailfield in the samemultipart/form-datarequest that creates the post. Typical browser recipe: load the file into a<video>, seek to ~1s, draw the frame to a<canvas>,canvas.toBlob(), append the blob asthumbnail. The server stores it as-is and never derives one itself, sovideo.thumbnailisnullwhenever the client omits it.
4. Show / update / delete
GET /api/v1/feed/posts/{post} → post incl. comments (per-type `reactions` are on every post response)
PUT /api/v1/feed/posts/{post} (author only, else 403) — body / visibility
DELETE /api/v1/feed/posts/{post} (author only, else 403) — cascades comments, reactions, poll
5. Share / repost
POST /api/v1/feed/posts/{post}/share → 201 { "data": { "post": { …type: share… } } }
Creates a new share post referencing the original (shared_post) and increments the
original's share_count. Sharing a share flattens to the root post (no infinite nesting).
| Field | Type | Required | Notes |
|---|---|---|---|
body |
string | no | Optional quote/commentary; ≤ 10000 |
visibility |
enum | no | public (default) · connections |
Hashtags
Hashtags are auto-parsed from a post's body on every create / update / share — clients simply
write #tags inline, there is no separate field to send. A tag must start with a letter and may
contain letters, numbers and underscores (#laravel, #dev_ops, #100DaysOfCode); tokens are
lowercased, deduplicated and capped at 100 chars, and purely numeric tokens (#123) are ignored.
Each unique tag is stored once and reused across posts; editing a post's body re-syncs its tags.
The resolved list is returned as hashtags on the post object.
Finding posts for a tag uses global search in hashtag mode (
GET /api/v1/search?h={tag}) — there is no separate posts-by-hashtag endpoint.
6. List / search hashtags
GET /api/v1/feed/hashtags → paginated hashtag objects, most-used first
GET /api/v1/feed/hashtags?search=lara → only tags whose name contains "lara" (autocomplete)
GET /api/v1/feed/hashtags?per_page=20
Lists hashtags that are used by at least one post, ranked by posts_count (descending, newest as
tiebreaker). Pass search to filter by name — a leading # is ignored and the match is a
case-insensitive substring, so it doubles as a hashtag autocomplete. Returned as a paginated
resource-collection under hashtags.
| Param | Type | Default | Notes |
|---|---|---|---|
search |
string | — | Optional name filter (≤ 100 chars); leading # ignored, substring match |
per_page |
int | 20 | 1–50 |
Reactions
One reaction per user per target; re-reacting changes the type without double-counting.
Works on both posts and comments (reaction_count kept in sync). Reacting to another user's
post notifies its author (feed.post_reacted).
POST /api/v1/feed/posts/{post}/reactions body: { "type": "love" } → { "data": { "reaction_count": n } }
DELETE /api/v1/feed/posts/{post}/reactions → { "data": { "reaction_count": n } }
POST /api/v1/feed/comments/{comment}/reactions body: { "type": "like" } → { "data": { "reaction_count": n } }
DELETE /api/v1/feed/comments/{comment}/reactions → { "data": { "reaction_count": n } }
Payload: type (required, one of the reaction enum values). Invalid type → 422.
Comments
GET /api/v1/feed/posts/{post}/comments → paginated top-level comments (newest first, 3-reply preview)
GET /api/v1/feed/comments/{comment}/replies → paginated replies (oldest first) under "data": { "replies": … }
POST /api/v1/feed/posts/{post}/comments → 201 { "data": { "comment": { … } } }
DELETE /api/v1/feed/comments/{comment} (author only, else 403)
List query params (both GETs): per_page (optional, 1–50, default 15).
The comments listing returns only top-level comments, each with a 3-reply preview plus the
full replies_count. Fetch the rest of a thread from the replies endpoint, which 422s if the
target is itself a reply.
Create payload: body (required, ≤ 5000), parent_id (optional; one-level threading — must
reference a top-level comment on this post). Commenting increments the post's
comment_count and notifies the post author (feed.post_commented) unless they are the
commenter.
Deleting a comment cascades its replies, and comment_count is decremented by the whole
removed subtree (the parent plus its replies).
Errors: 403 not the author (delete) · 422 missing body, or a parent_id that does not
exist, belongs to a different post, or is itself a reply.
Polls
POST /api/v1/feed/posts/{post}/poll/vote body: { "option_ids": ["…"] } → { "data": { "poll": { … } } }
DELETE /api/v1/feed/posts/{post}/poll/vote → retracts the caller's vote(s)
Voting replaces any previous vote by the caller. Rules:
- Single-choice polls (
allows_multiple: false) reject more than one option → 422. - Multi-choice polls accept multiple
option_ids. - Options must belong to the poll → 422 otherwise.
- Voting on a closed poll (past
expires_at) → 422.
Per-option vote_count and the caller's voted_by_me flags are recomputed and returned.
Profile
GET /api/v1/feed/users/{username} → { "data": { "user": { …profile object… } } }
Shows a single user's public feed profile, resolved by username (not the UUID used by the
connection/reaction routes). Returns connection/post counts, the caller's connection_status, and
an is_me flag. An unknown username → 404.
This is the trimmed profile, sized for a feed card. For the complete profile — role, expertise areas, experiences, education, certifications, location, and social links — call
GET /api/v1/user/{username}(see the Authentication API docs).
GET /api/v1/feed/users/{username}/posts → paginated posts by this author
GET /api/v1/feed/users/{username}/posts?sort=recent
Lists the author's posts (same post object and pagination shape as the timeline), also
resolved by username. Honours the same sort / per_page params as the timeline. Visibility
is enforced: the author sees all their own posts; a connection additionally sees the author's
connections-only posts; everyone else sees public posts only. Unknown username → 404.
Connections
A mutual relationship established by a request/accept handshake (LinkedIn-style). One user sends a request; the other accepts; both are then connected. Either side can remove it.
POST /api/v1/feed/connections/{user} → 201 { "data": { "connection_status": "pending_outgoing" } }
POST /api/v1/feed/connections/{user}/accept → { "data": { "connection_status": "connected" } }
DELETE /api/v1/feed/connections/{user} → { "data": { "connection_status": "none" } }
GET /api/v1/feed/connections → paginated connection-user objects (caller's connections)
GET /api/v1/feed/connections/pending → paginated connection-user objects (incoming requests)
GET /api/v1/feed/connections/sent → paginated connection-user objects (outgoing requests you sent)
GET /api/v1/feed/users/{username}/connections → paginated connection-user objects (a user's connections)
Mind the binding. The three
connections/{user}write routes resolve the target by UUID. Theusers/{username}/connectionslist resolves by username, matching the other profile-shaped routes (users/{username},users/{username}/posts). Passing a UUID there returns 404.
- Send a request (
POST connections/{user}) — creates apendingedge and notifies the target (feed.connection_request). Idempotent for a request you already sent. If the target had already sent you a request, this auto-accepts it (returnsconnected). Connecting with yourself → 422; requesting someone you're already connected to → 422. - Accept (
POST connections/{user}/accept) — accepts an incoming pending request from{user}and notifies the requester (feed.connection_accepted). No pending request → 422. - Remove (
DELETE connections/{user}) — one idempotent verb that cancels a request you sent, declines an incoming request, or removes an existing connection. - Incoming vs outgoing —
connections/pendinglists requests others sent you (rows carryconnection_status: "pending_incoming");connections/sentlists requests you sent (rows carrypending_outgoing), so a user can review andDELETE(cancel) them. - Lists annotate each row with the caller's
connection_statusand acceptper_page.
Suggested profiles
GET /api/v1/feed/suggestions → paginated profiles to connect with
GET /api/v1/feed/suggestions?per_page=20
A paginated "who to connect with" list, ranked by how well each candidate matches the caller. The caller and anyone they already have an edge with (pending or connected) are excluded; inactive/banned users are omitted.
Ranking blends three signals (higher is better):
score = shared_expertise_areas * 3
+ mutual_connections * 2
+ LN(connections_count + 1)
- shared_expertise_areas — overlap between the caller's and the candidate's expertise areas, drawn from both mentor and mentee profiles.
- mutual_connections — people connected to the caller who are also connected to the candidate (connection-graph proximity).
- connections_count — overall popularity (log-dampened tiebreaker).
When the caller has no expertise signal, ranking degrades gracefully to the connection-graph and
popularity terms. Each row exposes the "why" (and always connection_status: "none", since linked
users are excluded):
{
"id": "7d2e…",
"username": "john_doe",
"name": "John Doe",
"bio": "Building things.",
"avatar_url": null,
"connections_count": 128,
"mutual_connections_count": 3,
"shared_expertise_count": 2,
"connection_status": "none"
}
| Param | Type | Default | Notes |
|---|---|---|---|
per_page |
int | 15 | 1–50 |
Report
POST /api/v1/feed/posts/{post}/report → 201
POST /api/v1/feed/comments/{comment}/report → 201
Payload: reason (required, one of the report-reason enum values), details (optional,
≤ 2000). One report per user per target — a duplicate returns 409. Reports are stored with
status: pending; no moderation surface consumes them yet (see below).
Deferred / cross-phase (not in this cut)
| Item | Present | Unblocks with |
|---|---|---|
| Admin moderation surface (list/resolve reports, hide/remove content) | feed_reports captured (status column) |
Future moderation cycle |
| Queued notification fan-out; email / push channels | database channel (synchronous) | Phase 10 — Notifications / queue worker |
| Blocking / mute between users | — | Future feed cycle |
| Mentions / advanced full-text search (relevance-ranked, typo-tolerant) | LIKE-based post search over body + hashtag shipped (see Search & Hashtags) |
Future feed cycle |