Privacy API
Thirteen controls on one screen, each either "who can see this" or "who can do this to me". Two endpoints write them; every enforcement lives on the endpoints that serve the data.
All endpoints require auth:sanctum and are scoped to $request->user(). There is no {user}
segment: a token can only ever read or write its own controls. Storage is the existing
user_settings row โ see User Settings.
1. Design
The enums are the single source of truth. Which controls exist, what each defaults to, its copy, its value set, its storage column, and whether a booking overrides it are all declared once:
| Piece | Path | Role |
|---|---|---|
PrivacySettingKey |
app/Enums/Setting/PrivacySettingKey.php |
One case per control: copy, default, value set, column, validation rules, denial message |
PrivacyAudience |
app/Enums/Setting/PrivacyAudience.php |
public / connections / only_me |
PrivacyReach |
app/Enums/Setting/PrivacyReach.php |
everyone / connections / no_one |
ConnectionRequestAudience |
app/Enums/Setting/ConnectionRequestAudience.php |
everyone / connections_of_connections / no_one |
PrivacyRelation |
app/Enums/Setting/PrivacyRelation.php |
How a viewer stands toward a subject: self, connection, connection-of-connection, other |
PrivacyGuard |
app/Services/Setting/PrivacyGuard.php |
The only thing that decides. Singleton โ see ยง7 |
EnsurePrivacyAllows |
app/Http/Middleware/EnsurePrivacyAllows.php |
Route-declared gates, aliased privacy |
PrivacyRestrictionException |
app/Exceptions/Setting/PrivacyRestrictionException.php |
A denial. Rendered as 403 |
The rule lives on the value, not in the guard. Each value enum implements
PrivacyValue::permits(PrivacyRelation), so PrivacyGuard has no branch per key and adding a
value never touches it:
// PrivacyAudience
public function permits(PrivacyRelation $relation): bool
{
return match ($this) {
self::Public => true,
self::Connections => in_array($relation, [PrivacyRelation::SelfUser, PrivacyRelation::Connection], true),
self::OnlyMe => $relation === PrivacyRelation::SelfUser,
};
}
A row is written only when something changes, exactly as the other settings groups work. A user who has never opened the screen has no row, and reads fall back to the defaults on the enum.
connections means an accepted connection in both directions. A follower is not a connection,
and PrivacyRelation has no follower tier for that reason.
2. GET /api/v1/settings/privacy
Every key present, defaults filled in. Reading writes no row.
{
"status": "success",
"message": "Privacy settings retrieved.",
"data": {
"privacy_settings": {
"profile_visibility": "public",
"post_visibility": "public",
"course_visibility": "public",
"community_visibility": "connections",
"connection_visibility": "connections",
"contact_info_visibility": "only_me",
"connection_request_from": "everyone",
"message_from": "everyone",
"comment_from": "everyone",
"mention_from": "everyone",
"searchable_by": "everyone",
"search_engine_indexing": true,
"show_activity_status": true
},
"settings": [
{
"key": "message_from",
"label": "Who can message you",
"description": "People you have a confirmed session with can always message you.",
"value": "everyone",
"options": {
"everyone": "Everyone",
"connections": "Connections only",
"no_one": "No one"
}
}
],
"options": { "message_from": { "everyone": "Everyone", "connections": "Connections only", "no_one": "No one" } }
}
}
Three shapes of the same data, deliberately. privacy_settings is the flat map to bind form state
to; settings is the ordered, pre-labelled list to render the screen from, each row carrying the
values it accepts; options is every key's value set on its own, for a client that renders its own
rows. They never disagree.
Because labels, descriptions and options ship from the API, a new control appears on the screen without a frontend release, and the copy cannot drift between the two codebases.
3. PUT /api/v1/settings/privacy
A sparse partial update โ only the keys you send are changed. Each control saves on change, so the usual body is one key.
{ "message_from": "connections" }
Responds 200 with exactly the same body shape as the GET, after the write. An unknown key is
ignored. A known key carrying a value outside its own set is a 422 and nothing in the request
applies, including the valid keys beside it.
Validation is generated from the enum, so only_me is rejected on message_from even though it is
a valid PrivacyAudience value elsewhere.
4. GET /api/v1/settings
The single-request read now carries three groups:
{
"data": {
"settings": {
"notifications": { "session_updates": true },
"general": { "theme": "system" },
"privacy": { "message_from": "everyone" }
}
}
}
Nothing was added to /me, which stays identity-only.
5. The controls
| Key | Values | Default | Enforced on |
|---|---|---|---|
profile_visibility |
audience | public |
GET /user/{username} and GET /feed/users/{username} โ outside the audience the payload is name and photo only |
post_visibility |
public / connections |
public |
The audience a new post takes when the composer sends none. Existing posts are never rewritten |
course_visibility |
audience | public |
GET /user/{username}/courses โ enrolled courses drop out, taught courses stay |
community_visibility |
audience | connections |
GET /user/{username}/communities โ joined communities drop out, owned ones stay |
connection_visibility |
audience | connections |
The connections, followers and following lists, and the three counts on both profile payloads |
contact_info_visibility |
audience | only_me |
location and social_links on the profile resource |
connection_request_from |
connection_request | everyone |
POST /feed/connections/{user}. Following is unaffected |
message_from |
reach | everyone |
Opening a new chat thread. An existing thread is unaffected |
comment_from |
reach | everyone |
POST /feed/posts/{post}/comments. Reactions and shares are unaffected |
mention_from |
reach | everyone |
The @ picker, and whether a hand-typed mention links |
searchable_by |
reach | everyone |
People search and the mentor listings |
search_engine_indexing |
boolean | true |
X-Robots-Tag on the profile response, plus a field in the body |
show_activity_status |
boolean | true |
Online and last-seen, in both directions โ see ยง6 |
Three defaults are restrictive
contact_info_visibility is only_me, and connection_visibility and community_visibility are
connections. They apply to every account, including ones that never opened the screen, so a
stranger sees null contact detail, null counts, a 403 on the connection lists, and no joined
communities.
show_activity_status shares a column
It is the Privacy screen's name for show_online_status, which shipped with the General tab and
which PresenceService already plucks in bulk. PrivacySettingKey::ShowActivityStatus->column()
returns show_online_status, so one stored fact serves both screens and writing either moves both.
6. What enforcement does, surface by surface
A restricted profile
200 with identity only, never an error. only_me on a profile is not a block: the same person
appears under their comments and in community member lists, and blanking them there would turn
those surfaces into anonymous text.
{
"data": {
"user": {
"id": "...",
"username": "jane",
"name": "Jane Doe",
"avatar_url": "...",
"is_restricted": true,
"connection_status": "none",
"is_connected": false,
"is_following": false,
"is_me": false
}
}
}
Every other field is absent, not null. is_restricted is present and false on a full
profile. Follow and Connect stay available โ that is how a viewer asks for access.
Null counts
connections_count, followers_count and following_count come back null, with
connections_visible: false beside them. Null rather than zero: zero is a fact about the account,
and this is the absence of one. posts_count is never gated.
The 403s
| Request | Denied when |
|---|---|
GET /feed/users/{username}/connections |
outside connection_visibility |
GET /feed/users/{username}/followers |
outside connection_visibility |
GET /feed/users/{username}/following |
outside connection_visibility |
POST /feed/connections/{user} |
outside connection_request_from |
POST /chat/users/{user}/messages |
outside message_from, and only when no thread exists |
POST /feed/posts/{post}/comments |
outside comment_from |
Body is the standard envelope, and the message is written to be shown as it stands:
{ "status": "error", "message": "This person is not accepting messages from you.", "data": {} }
A block is still a 404, not a 403. A block reads as gone, privacy reads as closed, and the
order of the checks in every service is block first, privacy second โ a privacy denial must never
reveal that a block is what the caller actually hit.
Quieter lists
No error and no flag, just fewer rows: people search and the mentor listings drop users who opted
out of searchable_by (and the listings also drop non-public profiles), the @ picker drops users
who opted out of mention_from, and a mention typed by hand for one of them saves as plain text.
MentorService::countBookableMentors() takes the viewer for this reason โ the dashboard card and
the listing beneath it apply the same two exclusions, in the same order, so they cannot disagree.
Reciprocal presence
A user who turns show_activity_status off stops seeing everyone else's status too: every
presence block they receive reads is_online: false with last_seen_at: null. Without this the
switch is a one-way mirror, which is not what it promises. A user always sees their own true
status.
A settled booking overrides two controls
The two people on a confirmed or completed booking can always message each other and always see
each other's contact detail, whatever message_from and contact_info_visibility say. Otherwise
someone pays for a session and then cannot reach the mentor. Declared as
PrivacySettingKey::bookingOverridable() and applied inside the guard, so no call site carries the
special case.
7. PrivacyGuard
A service rather than a Policy: the question is one user's stance toward another rather than an
ability on a model (Gate::policy(User::class) already belongs to AccountModerationPolicy), and
two keys have to be enforced inside SQL, which a policy cannot do.
Registered as a singleton in AppServiceProvider. A feed page asks the same questions about
the same authors on every row, and the relation, settings, connection and booking memos are what
keep enforcement off the query budget.
Four entry points:
// Read: may this viewer see it?
$guard->allows($viewer, $target, PrivacySettingKey::ContactInfoVisibility);
// Write: deny loudly, rendered as 403.
$guard->assert($actor, $target, PrivacySettingKey::CommentFrom);
// Query: exclude the opted-out inside SQL.
$guard->excludeHidden($query, $viewer, PrivacySettingKey::SearchableBy);
// List: resolve a whole page in two queries instead of two per row.
$guard->warm($users, $viewer);
excludeHidden() is an exclusion, never an inclusion, so a user with no settings row is never
dropped โ no row means "never configured", and every gated key defaults to permissive. The excluded
values are derived from the value enum's own permits(), not listed in the guard.
Where each gate lives
Four gates are declared on the route, via the privacy middleware alias:
Route::get('users/{user:username}/connections', [ConnectionController::class, 'userConnections'])
->middleware('privacy:connection_visibility,user')
->name('feed.users.connections');
privacy:<key>,<route parameter>. A dotted path (post.author) reaches one hop through a bound
model. The middleware fails closed: a parameter it cannot resolve throws rather than passing,
because a gate that quietly allows reads as enforced while enforcing nothing.
The rest cannot be routes, and the reason matters when adding the next one:
| Gate | Why it is in code |
|---|---|
comment_from |
{post} arrives as a raw id the controller resolves โ no bound model to read the author off |
message_from |
Waived once a thread exists; the router cannot see that state |
profile_visibility, contact_info_visibility, connection_visibility |
They shape a payload rather than allow or deny a request |
course_visibility, community_visibility |
They filter rows inside a list that still returns 200 |
searchable_by, mention_from |
They narrow a result set, inside the query |
post_visibility |
A default applied at write time, with no viewer involved |
8. Storage
Twelve columns added to user_settings (show_activity_status reuses show_online_status), with
an index on searchable_by and profile_visibility โ the two keys read as a population rather
than per user.
Typed columns rather than a JSON blob, for the same reason the table was built that way: user
search and the mentor listings compose a subquery against those two columns, and JSON_EXTRACT on
every candidate row would cost the listing an unindexed scan.
No backfill. Accounts with no row read the defaults, so nothing runs beyond php artisan migrate.
9. Tests
| File | Covers |
|---|---|
tests/Feature/Setting/PrivacySettingTest.php |
The endpoints: defaults, partial writes, 422 with nothing applied, the shared activity-status column, scoping to the caller |
tests/Feature/Setting/PrivacySettingEnforcementTest.php |
Every control, from the outside, plus the route-declared gates and the fail-closed middleware |
Restrictive defaults mean a feature test whose subject is something else has to open the gate
explicitly. Use the setPrivacy() helper in tests/Pest.php:
setPrivacy($subject, ['connection_visibility' => 'public']);