API
Documentation / API Reference / Privacy

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']);