Social Feed Module¶
Last reviewed: 2026-06-23
This guide covers the Social Feed module in Project NEXUS — the unified activity stream, standalone posts, polls, stories, comments, reactions, sharing, and the EdgeRank-style ranking algorithm. It is for maintainers adding features, fixing bugs, or changing ranking behaviour.
Audience and supported workflows¶
Use this guide when touching:
- Feed post create/edit/delete, including scheduled and draft posts
- Polls (create, vote, export, ranked-choice results)
- Stories (24-hour disappearing media with inline polls)
- Comments and threaded replies
- Reactions on any feed entity
- Sharing / quote-reposting
- Bookmarking / saved filter
- Feed ranking (EdgeRank 15-signal pipeline)
- Moderation and admin controls
- Per-post analytics (views, clicks, CTR)
Tenant and feature-gate rules¶
| Gate | Type | Key | Default |
|---|---|---|---|
| Feed module | Module (core, see TenantContext::hasFeature) |
feed |
ON |
| Polls standalone page | Feature flag | polls |
ON |
| Individual event/goal/job/challenge/blog filter pills on the feed | Feature flags | events, goals, job_vacancies, ideation_challenges, volunteering, blog, groups |
per-feature default |
- Module gate
feed— all feed routes inApp.tsxare wrapped with<FeatureGate module="feed" redirect="/dashboard">. When the module is disabled, the feed nav link and all/feed/*routes redirect to/dashboard. - Feature gate
polls— the standalone/pollspage uses<FeatureGate feature="polls">. The polls filter pill on the feed also gates onhasFeature('polls'). The/v2/feed/polls/*inline poll endpoints follow the same gate via theSocialController. - Stories share the feed module gate; there is no separate
storiesfeature flag inTenantFeatureConfig::FEATURE_DEFAULTS. - All queries are tenant-scoped.
FeedServicejoinsuserswithWHERE u.tenant_id = ?. All sub-queries (bookmarks, post_shares, likes, comments, connections, group_members) include explicittenant_idguards. TheFeedActivitymodel carries aHasTenantScopeglobal scope. - Admin endpoints (
/v2/admin/feed/*,/v2/admin/comments/*,/v2/admin/polls/*) require theadminmiddleware and are additional to user-facing gates.
Key code and data locations¶
Routes are defined in routes/api.php. Do not copy the endpoint table here; read the route file for the live list. Entry-point summary:
| Concern | Route prefix | Controller |
|---|---|---|
| Feed (read, post, like, hide, mute, share) | /v2/feed/* |
App\Http\Controllers\Api\SocialController, FeedSocialController |
| Inline polls (create, get, vote) | /v2/feed/polls/* |
SocialController |
| Standalone polls (CRUD, ranked-choice, export) | /v2/polls/* |
App\Http\Controllers\Api\PollsController |
| Stories | /v2/stories/* |
App\Http\Controllers\Api\StoryController |
| Comments | /v2/comments/* |
App\Http\Controllers\Api\CommentsController |
| Reactions (polymorphic) | /v2/reactions/* |
App\Http\Controllers\Api\ReactionController |
| Post analytics (views, clicks) | /v2/feed/posts/{id}/view, /analytics |
App\Http\Controllers\Api\PostAnalyticsController |
| Feed sidebar (stats, suggestions) | /v2/feed/sidebar |
App\Http\Controllers\Api\FeedSidebarController |
| Admin feed moderation | /v2/admin/feed/*, /v2/admin/comments/*, /v2/admin/polls/* |
AdminFeedController, AdminCommentsController, AdminPollsController |
| Admin algorithm config | /v2/admin/config/feed-algorithm |
AdminConfigController |
Services:
app/Services/FeedService.php— unified feed query (getFeed), post CRUD (createPost,updatePost,deletePost), scheduled-post publisher, poll batch loading.app/Services/FeedRankingService.php— full 15-signal EdgeRank pipeline, CTR tracking, diversity reordering.app/Services/PersonalisedFeedService.php— personalisation wrapper (called insideSocialController::feedV2afterFeedService::getFeed).app/Services/PollService.php— standalone poll CRUD with hidden-totals ballot rule.app/Services/StoryService.php— story create/view/react/reply/highlight/archive, 24h expiry, story-inline polls.app/Services/CommentService.php— threaded comments on any entity, emoji reactions on comments.app/Services/ReactionService.php— polymorphic emoji reactions (8 named types) with toggle/replace semantics and a DB-level unique index.app/Services/FeedSocialController.php/app/Services/FeedSocialService.php— share/unshare,shared_byhydration, trending/search hashtags.app/Services/FeedActivityService.php— low-levelfeed_activityrow insertion and deletion (called by other services when content is published/deleted).app/Services/MentionService.php—@mentionextraction and notification fanout on post create/publish.app/Services/LinkPreviewService.php— batch-loads URL preview cards for post items.app/Services/ShareService.php— polymorphic share count,is_sharedflags,post_sharestable.app/Services/BookmarkService.php— bookmark toggle; thesavedfeed filter joinsbookmarkson(bookmarkable_type, bookmarkable_id).app/Services/ContentModerationService.php—detectSpam()regex called on every new post; spam-flagged posts are hidden and queued incontent_moderation_queue.app/Services/PostMediaService.php/app/Services/PostViewService.php— media attachments and per-user view tracking.app/Services/GroupScheduledPostService.php— group-scoped scheduled-post management.
Models:
app/Models/FeedActivity.php— unified activity row (HasTenantScope)app/Models/FeedPost.php— post recordapp/Models/Poll.php,app/Models/PollOption.php— poll and its optionsapp/Models/Comment.php— comment withparent_idfor threadingapp/Models/PostView.php— per-user post view record
Tables:
| Table | Purpose |
|---|---|
feed_activity |
Unified feed stream; one row per published content item (post, listing, event, poll, …) |
feed_posts |
Post body, visibility, publish_status, scheduled_at, quoted_post_id, is_official, is_hidden, deleted_at |
post_media |
Attachments for a post (images, video); ordered by display_order |
polls |
Poll question, end_date, is_active, user_id |
poll_options |
One row per option per poll |
poll_votes |
One row per vote; (poll_id, option_id, user_id, tenant_id) |
stories |
24-hour stories; expires_at, is_active, audience, inline poll columns |
story_views |
INSERT IGNORE per viewer |
story_reactions |
Reaction per (story_id, user_id) — toggle semantics |
story_highlights |
Named highlight albums |
story_analytics |
Per-event watch telemetry (event_type, watch_duration_ms) |
story_stickers |
Sticker metadata persisted per story |
comments |
Polymorphic comments (target_type, target_id); parent_id for threading; soft-deleted via deleted_at |
likes |
Legacy like table; also used for CTR/engagement batch queries |
reactions |
Polymorphic named-type reactions (target_type, target_id, emoji); unique index per (user_id, target_type, target_id) |
post_shares |
(original_type, original_post_id, user_id, tenant_id) — one share per user per item |
bookmarks |
(bookmarkable_type, bookmarkable_id, user_id, tenant_id) |
feed_hidden / user_hidden_posts |
Per-user hidden items (two legacy tables — both checked) |
feed_muted_users / user_muted_users |
Per-user muted authors (two legacy tables — both checked) |
feed_impressions |
CTR impression counts (target_type, target_id, user_id); ON DUPLICATE KEY increments view_count |
feed_clicks |
CTR click counts — same schema as impressions |
content_moderation_queue |
Spam-flagged posts queued for admin review |
Frontend entry points:
react-frontend/src/pages/feed/FeedPage.tsx— main feed with filter tabs, ranked/chronological mode toggle, compose buttonreact-frontend/src/pages/feed/PostDetailPage.tsx— single post / any feed item permalink (/feed/posts/:id,/feed/item/:type/:id)react-frontend/src/pages/feed/HashtagPage.tsx,HashtagsDiscoveryPage.tsxreact-frontend/src/pages/polls/PollsPage.tsx— standalone polls list/detailreact-frontend/src/components/feed/FeedCard.tsx— the universal feed card componentreact-frontend/src/components/feed/FeedContentRenderer.tsx— renders post body with mentions, hashtags, link previewsreact-frontend/src/components/stories/StoryViewer.tsx,StoryCreator.tsx,StoryHighlights.tsxreact-frontend/src/components/compose/tabs/PollTab.tsx— poll creator inside the compose modalreact-frontend/src/components/social/SocialInteractionPanel.tsx— reactions, comments, share row under a card
Feed architecture¶
Unified feed stream¶
All content types — posts, listings, events, polls, goals, jobs, challenges, volunteer opportunities, blog posts, reviews, badge_earned, level_up — publish a row to feed_activity when they go live. FeedService::getFeed queries only feed_activity and batch-loads enrichment (counts, poll data, quotes, link previews, share attribution). This avoids per-type fan-out queries.
Feed modes¶
| Mode | Behaviour |
|---|---|
ranked (default) |
EdgeRank 15-signal in-memory sort (see Ranking section) |
chronological / recent |
No ranking; pure (created_at DESC, id DESC) |
Mode is passed as ?mode=ranked or ?mode=chronological on GET /v2/feed.
Virtual feed filters¶
type=saved — joins bookmarks on (bookmarkable_type, bookmarkable_id). Anonymous users always get an empty result.
type=following — filters to items authored by users with whom the viewer has an accepted connection in either direction.
Cursor pagination¶
The cursor is a base64-encoded HMAC-SHA256 signed JSON payload {ts, id} where ts is the created_at of the last seen item and id is the feed_activity.id. The HMAC uses config('app.key'). Unsigned legacy cursors (base64 of created_at|id) are accepted for backwards compatibility. Ranked mode advances the cursor from the chronological tail of the scored candidate pool so the next page resumes at the correct database position.
Post visibility¶
FeedService::applyPostVisibilityScope enforces post-level privacy at query time:
visibility = 'public'orNULL— visible to everyone.visibility = 'friends'or'connections'— visible only to the author and users with anacceptedconnection.visibility = 'private'— visible only to the author.- Posts with
publish_status = 'draft'or'scheduled'are excluded from the feed. - Posts with
deleted_at IS NOT NULLare excluded. - Posts with
is_hidden = 1are excluded (admin-hidden).
Group-scoped posts¶
Posts inside private groups are hidden from non-members. The visibility query ORs: group is NULL (public post), viewer is an active group member, or the group's own visibility = 'public'.
Post content limits¶
- Maximum body length:
FeedService::MAX_POST_LENGTH = 50000bytes. - Scheduling:
scheduled_atmust be in the future and no more than 365 days ahead. Schedules that fall in the past publish immediately. - Quotes:
quoted_post_idis validated viaFeedItemTables::canViewbefore saving.
Posts¶
Creating a post (POST /v2/feed/posts → SocialController::createPostV2 → FeedService::createPost):
- Content is sanitised by
App\Helpers\HtmlSanitizer::sanitize. - Spam detection (
ContentModerationService::detectSpam) — match setsis_hidden = 1and inserts intocontent_moderation_queue; post is saved but not visible. - If the author has the
municipality_announcerrole,is_official = trueandis_pinned = trueare set automatically. - For
publish_status = 'published', afeed_activityrow is inserted (INSERT OR IGNORE) and@mentionsare processed byMentionService::processText. - For
publish_status = 'scheduled'or'draft', nofeed_activityrow is written; the post becomes visible when published.
Updating a post (PUT /v2/feed/posts/{id}): only the original author can edit. Content is re-sanitised and feed_activity.content / image_url are updated in sync.
Deleting a post (DELETE /v2/feed/posts/{id}): soft-delete via deleted_at; feed_activity row is removed by FeedActivityService::deleteActivity.
Polls¶
Polls are a first-class feed type, surfaced both inline in the feed (/v2/feed/polls/*) and on a standalone page (/v2/polls/*). They share the polls / poll_options / poll_votes tables.
Fair-voting hidden-totals rule¶
While a poll is open (is_active = true AND end_date has not passed), FeedService::batchLoadPollData and PollService::getById both enforce:
- Non-creator viewers receive
nullforvote_countandpercentageon every option. total_votesis also withheld from non-creators on open polls.- The poll creator always sees full running totals.
Once the poll is closed (end_date <= now), full totals and percentages are returned to all viewers.
This prevents bandwagon/strategic voting where early-result knowledge changes later voters' behaviour.
Voting¶
POST /v2/polls/{id}/vote records one vote per user. A user changing their vote replaces the existing poll_votes row. The vote_count in the response respects the hidden-totals rule above.
Ranked-choice and export¶
POST /v2/polls/{id}/rank stores a preference ordering.
GET /v2/polls/{id}/ranked-results returns the Condorcet-style tally.
GET /v2/polls/{id}/export streams a CSV of anonymised vote data (admin only via AdminPollsController).
Story-inline polls¶
Stories can carry a poll question with 2–4 options (stored as JSON in stories.poll_options). POST /v2/stories/{id}/poll/vote records a vote. Totals follow the same hidden-until-close rule as feed polls — the story expires_at is the poll close time.
Stories¶
Stories are 24-hour ephemeral media managed by StoryService. Key behaviours:
- Capacity guard — maximum 30 active stories per user.
StoryService::createuses aSELECT ... FOR UPDATElocking count inside a transaction to prevent concurrent requests from bypassing the limit. - Lifetime —
stories.expires_at = NOW() + 24 hours. Stories withexpires_at <= NOW()are excluded from listing queries. - Media types —
image,video,text,poll. Video duration is stored instories.video_duration; the display duration is clamped to 3–30 seconds. - Audience —
stories.audiencefield (e.g.'everyone','close_friends'). Close-friend lists are managed via/v2/stories/close-friends/*. - View tracking —
POST /v2/stories/{id}/viewdoesINSERT IGNORE INTO story_views; viewer IDs are returned byGET /v2/stories/{id}/viewers. - Reactions — toggle via
POST /v2/stories/{id}/react; one reaction type per(story_id, user_id). - Replies —
POST /v2/stories/{id}/replysends a direct message to the story author. - Highlights — permanent named albums (
story_highlights); items added / removed / reordered. - Analytics —
POST /v2/stories/{id}/analyticsrecords watch events (event_type,watch_duration_ms) tostory_analytics. - Stickers — arbitrary sticker metadata saved to
story_stickersper story.
Comments¶
Comments use a polymorphic model (comments.target_type / target_id) and support one level of threading via parent_id. The same comments table is used for posts, listings, events, polls, goals, and any other commentable entity.
CommentService::getForEntity returns top-level comments with nested replies, each enriched with the commenter's reaction summary from the reactions table (target_type = 'comment').
Soft-deletes are supported: if deleted_at is present (detected at runtime via Schema::hasColumn('comments', 'deleted_at')), deleted comments are excluded from counts and listings.
Comment visibility inherits from the parent entity — CommentService::targetIsCommentableAndVisible checks FeedItemTables::canView before returning data.
Admin endpoints (/v2/admin/comments/*) allow hiding and deleting comments by moderators.
Reactions¶
ReactionService::toggleReaction implements toggle / replace semantics:
- Same type → toggle off (removes the row).
- Different type → replace (updates the existing row to the new type).
- No prior reaction → add (inserts a new row).
A DB-level unique index on (user_id, target_type, target_id) in the reactions table prevents duplicate rows from concurrent requests. Serialisation is also enforced at the service level with a cache lock.
Valid named types: love, like, laugh, wow, sad, celebrate, clap, time_credit
Valid target types: post, comment, listing, event, goal, poll, review, volunteer, challenge, resource, job, blog, discussion
FeedRankingService::REACTION_WEIGHTS maps named types to scoring multipliers used in Signal 11 of the ranking pipeline:
| Type | Weight |
|---|---|
love |
2.0 |
celebrate |
1.8 |
insightful |
1.5 |
like |
1.0 |
curious |
0.8 |
sad |
0.6 |
angry |
0.5 |
Sharing / quote reposting¶
POST /v2/feed/posts/{id}/share (and the polymorphic POST /v2/feed/share) inserts a row into post_shares. One share per (user_id, original_type, original_post_id, tenant_id).
share_count on feed items comes from two sources:
- For
type = 'post': the denormalisedfeed_posts.share_countcolumn (updated byShareService). - For all other types: computed on demand by
ShareService::batchShareCount.
The shared_by field on feed items surfaces the most recent sharer who is not the original author and not the current viewer, so friends' reposts appear as social proof. On a profile feed view, the profile owner's own reposts take priority.
Quote reposting: creating a post with quoted_post_id embeds the quoted post in the feed_posts.quoted_post_id FK. The quoted post is batch-loaded with its author and media and returned as quoted_post in feed item payloads.
Feed ranking (EdgeRank)¶
FeedRankingService::rankFeedItems applies a 15-signal multiplicative scoring pipeline to the candidate pool returned by FeedService::getFeed, then applies diversity reordering.
Signals (in order applied)¶
| # | Signal | Key behaviour |
|---|---|---|
| 1 | Time Decay | Hacker News decay; 24h full score, 72h half-life, 0.30 floor |
| 2 | Engagement | Log-scaled likes × 1 + comments × 5; zero-engagement posts get a ×1.05 floor |
| 3 | Velocity | Rapid engagement within 2h window (threshold: 3 actions); boost up to ×1.8, decays over 6h |
| 4 | Content Type | Events ×1.4, challenges ×1.3, polls ×1.25, volunteer ×1.2, goals ×1.1, posts ×1.0, listings/jobs ×0.9, reviews ×0.8 |
| 5 | Social Affinity | Interaction-weighted score for viewer→author relationship (90-day window, up to ×2.0); falls back to connection-follower ×1.5 |
| 6 | Creator Vitality | Author recent-activity score; full at ≤7 days, decays to 0.50 at ≥30 days |
| 7 | Geo Decay | Haversine distance; full score within 50 km, exponential decay (λ=0.003), 0.15 floor |
| 8 | Content Quality | Images ×1.3, video ×1.4, links ×1.1, hashtags ×1.1, @mentions ×1.15, body ≥50 chars ×1.2 |
| 9 | Context Timing | Post/poll evening boost ×1.12; events on Monday morning ×1.20; volunteer on weekends ×1.18; late-night (2–6 am) post penalty ×0.90 |
| 10 | Conversation Depth | Thread depth ≥3 replies boosts up to ×1.5 |
| 11 | Reaction Weighting | Named-type reaction scores (see table above) |
| 12 | Negative Signals | Admin-hidden: score 0.0; each report: −0.15 (floor 0.1) |
| 13 | CTR Feedback | Click-through rate vs 2% neutral baseline; min 5 impressions to apply; max boost ×1.5 |
| 14 | User Type Prefs | Per-viewer content-type preference from 30-day interaction history; max boost ×1.4 |
| 15 | Save/Bookmark | Items saved ≥2 times get a save-signal boost; max ×1.35 |
After scoring, items are sorted descending by score, then diversity reordering prevents more than 2 consecutive items from the same author or 3 consecutive items of the same content type.
CTR tracking consent gate: FeedRankingService::recordImpression / recordClick check cookie_consents.analytics for the user before writing to feed_impressions / feed_clicks. No tracking if consent is 0 or if the check throws. Impressions are debounced per (user, type, id) with a 5-minute cache window.
Per-tenant algorithm configuration¶
Ranking weights and thresholds can be overridden per tenant via tenants.configuration.feed_algorithm (JSON). The PUT /v2/admin/config/feed-algorithm endpoint writes these. Defaults fall through from FeedRankingService::getConfig(). Invalid values are clamped before use.
Ranking reasons¶
Pass includeReasons=true to rankFeedItems to attach ranking_reasons (top 3 i18n-key signals) to each item. Used by the "why am I seeing this?" affordance.
Moderation and privacy¶
- Spam detection —
ContentModerationService::detectSpamon every new post; regex-matched posts are hidden automatically and queued incontent_moderation_queuefor admin review. - Admin hide —
POST /v2/admin/feed/posts/{id}/hidesetsfeed_activity.is_hidden = 1; the post disappears from all feeds without deletion. - User hide —
POST /v2/feed/posts/{id}/hideadds a row tofeed_hidden/user_hidden_posts; FeedService excludes these withWHERE NOT EXISTS. - User mute —
POST /v2/feed/users/{id}/muteadds tofeed_muted_users/user_muted_users; FeedService excludes all content from muted authors. - Block —
BlockUserService::getBlockedPairIdsexcludes blocked users in both directions. - Report —
POST /v2/feed/posts/{id}/reportrecords inreports; each report applies a−0.15ranking penalty (Signal 12). - Municipality announcer — users with the
municipality_announcerrole have their posts markedis_official = trueandis_pinned = trueautomatically byFeedService::createPost. - Orphan pruning —
FeedService::filterOutOrphanedItemsremovesfeed_activityrows whose source entity has been deleted from the backing table (listings, events, polls, etc.) to prevent ghost cards.
Security and privacy invariants¶
- All feed queries are tenant-scoped:
feed_activity.tenant_idand aJOIN users ... WHERE u.tenant_id = ?prevent cross-tenant user data leakage. - Post visibility (
public/friends/private) is enforced at the SQL level insideapplyPostVisibilityScope, not post-hoc in PHP. Connections check is done with a correlated sub-query on theconnectionstable, also scoped bytenant_id. - Private group posts are hidden from non-members by a
WHERE EXISTS (group_members)guard. - Poll votes are one-per-user enforced by the
poll_votesprimary or unique key; changing a vote replaces the row. - CTR data is withheld unless the user's
cookie_consents.analyticsis explicitly set (fail-closed). - Story view records use
INSERT IGNORE— no error on double-view, but no double-count either. - Reaction uniqueness is enforced by a DB-level unique index on
reactions (user_id, target_type, target_id)plus a service-level cache lock. - The HMAC-signed feed cursor prevents clients from forging arbitrary pagination positions.
Failure modes and recovery¶
| Failure | Effect | Recovery |
|---|---|---|
feed_activity row missing for a published post |
Post invisible in feed but still accessible via /feed/posts/{id} |
FeedActivityService::ensureActivity re-inserts the row |
| EdgeRank ranking throws | FeedService::getFeed catches \Throwable, logs a warning, and returns items in chronological order |
Restart is not needed; ranking is stateless |
| Link preview batch load fails | Feed is returned without link_previews (exception caught silently) |
No action; previews are cosmetic |
| Quoted post batch load fails | Feed is returned without quoted_post embeds (exception caught, warning logged) |
No action |
feed_impressions / feed_clicks insert fails |
Silently ignored; CTR signal drops to neutral for affected items | Check DB capacity |
| Spam flagging false positive | Legitimate post hidden, queued for moderation | Admin reviews content_moderation_queue via /v2/admin/feed/posts?status=flagged |
| Story max-stories limit race | StoryService::create uses SELECT ... FOR UPDATE; extra concurrent request receives a RuntimeException |
Client retries; safe |
| FeedRankingService static config cache stale after tenant switch | FeedRankingService::clearStaticCache() flushes; Octane workers re-pin on next request |
Call clearStaticCache() in queue-job handle() if ranking is called from a worker |
Tests¶
Run the feed test suite:
vendor/bin/phpunit tests/Laravel/Feature/Controllers/FeedControllerTest.php
vendor/bin/phpunit tests/Laravel/Feature/Controllers/SocialControllerTest.php
vendor/bin/phpunit tests/Laravel/Feature/Controllers/PollsControllerTest.php
vendor/bin/phpunit tests/Laravel/Feature/Controllers/StoryControllerTest.php
vendor/bin/phpunit tests/Laravel/Feature/Controllers/CommentsControllerTest.php
vendor/bin/phpunit tests/Laravel/Feature/Controllers/ReactionControllerTest.php
vendor/bin/phpunit tests/Laravel/Feature/Controllers/FeedSocialControllerTest.php
vendor/bin/phpunit tests/Laravel/Feature/Controllers/FeedSidebarControllerTest.php
vendor/bin/phpunit tests/Laravel/Feature/Personalisation/PersonalisedFeedTest.php
vendor/bin/phpunit tests/Laravel/Feature/GovukAlpha/FeedParityTest.php
Key regression tests:
SocialControllerTest— post CRUD, scheduled post publish, visibility filter, mute/hide/report flows,shared_byhydration.PollsControllerTest— hidden-totals rule (open poll, non-creator seesnullcounts; closed poll returns counts; creator always sees counts).StoryControllerTest— 30-story cap with concurrent-request simulation, story poll vote, reaction toggle.ReactionControllerTest— toggle-off, type-replace, invalid-type rejection, cross-tenant isolation.PersonalisedFeedTest— personalised ranking applied after base feed.FeedParityTest(accessible frontend) — GOV.UK-accessible feed routes return same content shape.
React tests: