featured image

Building Privacy-Aware API Resources with Laravel

Learn how to create API resources in Laravel that respect user privacy settings, ensuring data is shared appropriately based on viewer permissions.

Published

Mon Oct 06 2025

Technologies Used

Laravel PHP PostgreSQL SQL
Beginner 23 minutes

Permission Checks Scattered Across Controllers Are a Security Liability

On MyKpopLists, users control what strangers can see: some want public lists, private activity feeds, and friend-only comment history. Others want everything open. Each user has their own combination of settings.

The fragile way to implement this: check permissions in every controller method. You end up with conditional logic scattered across your codebase, and when privacy rules change, you have to hunt down every controller that touches that data. The fragile way also has a more immediate problem — it’s easy to forget a check somewhere. That’s a security vulnerability, not just a maintenance issue.

Laravel API Resources are the right place for this logic. They sit between your models and your JSON responses, and they’re the natural place to make decisions about what each viewer is allowed to see. A UserResource that’s aware of who’s viewing it can centralize all permission logic in one place, leaving controllers clean.

What You Need

Knowledge:

  • Basic PHP and object-oriented programming
  • Laravel models and relationships
  • REST API concepts
  • The difference between authentication (who you are) and authorization (what you can see)

Environment: PHP 8.2+, Laravel 12.x, SQLite or PostgreSQL.

The Smart Photo Album

An API Resource works like a photo album that shows different things to different people. You show everything to your spouse, hide childhood photos from coworkers, and show only vacation photos to acquaintances. The underlying album doesn’t change — but what each person sees depends on their relationship to you.

The data flow for a profile request looks like this:

graph TD
    A[HTTP Request] --> B[Controller]
    B --> C{User Authenticated?}
    C -->|Yes| D[Load Profile User]
    C -->|No| E[Guest Context]
    D --> F[UserResource]
    E --> F
    F --> G[PrivacyService Check]
    G --> H{Can View Content?}
    H -->|Yes| I[Include Data]
    H -->|No| J[Exclude Data]
    I --> K[JSON Response]
    J --> K

Why Controllers Shouldn’t Hold Privacy Logic

Here’s the fragile approach that seems reasonable until you have ten endpoints:

// Don't do this
class ProfileController extends Controller
{
    public function show(User $user)
    {
        $data = ['id' => $user->id, 'username' => $user->username];
        
        if (auth()->check() && auth()->id() === $user->id) {
            $data['email'] = $user->email;
        }
        if ($user->show_activity_tab) {
            $data['activities'] = $user->activities;
        }
        // Gets messier with each new field
        
        return response()->json($data);
    }
}

When you add a new field, you add a new permission check — in every controller that returns user data. When you change a privacy rule, you change it in every controller. This doesn’t scale.

The Resource Pattern With a Privacy Service

The UserResource receives two pieces of context: the authenticated user (viewer) and the profile being viewed (profile user). A dedicated PrivacyService handles all the permission decisions:

// app/Http/Resources/UserResource.php

class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        $viewer      = $request->user();
        $profileUser = $this->resource;
        $privacyService = app(PrivacyService::class);

        return [
            // Always visible
            'id'              => $this->id,
            'username'        => $this->username,
            'name'            => $this->name,
            'bio'             => $this->bio,
            'profile_picture' => $this->profile_picture,
            'created_at'      => $this->created_at,

            // Privacy-controlled content
            'lists' => $this->when(
                $privacyService->canViewUserContent($viewer, $profileUser, 'lists'),
                fn() => UserListResource::collection($this->whenLoaded('lists'))
            ),
            'reviews' => $this->when(
                $privacyService->canViewUserContent($viewer, $profileUser, 'reviews'),
                fn() => ReviewResource::collection($this->whenLoaded('reviews'))
            ),
            'posts' => $this->when(
                $privacyService->canViewUserContent($viewer, $profileUser, 'posts'),
                fn() => PostResource::collection($this->whenLoaded('posts'))
            ),
            'comments' => $this->when(
                $privacyService->canViewUserContent($viewer, $profileUser, 'comments'),
                fn() => CommentResource::collection($this->whenLoaded('comments'))
            ),
            'activities' => $this->when(
                $privacyService->canViewUserContent($viewer, $profileUser, 'activity'),
                fn() => UserActivityResource::collection($this->whenLoaded('activities'))
            ),
            'friends' => $this->when(
                $privacyService->canViewFriendsList($viewer, $profileUser),
                fn() => FriendshipResource::collection($this->whenLoaded('friends'))
            ),
            'followed_groups' => $this->when(
                $privacyService->canViewFollowingList($viewer, $profileUser),
                fn() => GroupResource::collection($this->whenLoaded('followedGroups'))
            ),
            'followed_idols' => $this->when(
                $privacyService->canViewFollowingList($viewer, $profileUser),
                fn() => IdolResource::collection($this->whenLoaded('followedIdols'))
            ),

            // Authenticated users only
            'friendship_status' => $this->when(
                $viewer !== null,
                fn() => $viewer->getFriendshipStatus($profileUser)
            ),
            'is_friend' => $this->when(
                $viewer !== null,
                fn() => $viewer->isFriendsWith($profileUser)
            ),

            // Profile owner only
            'email' => $this->when(
                $viewer && $viewer->id === $this->id,
                $this->email
            ),
            'unread_notifications_count' => $this->when(
                $viewer && $viewer->id === $this->id,
                fn() => $this->unreadNotifications()->count()
            ),

            // Always visible, but only if pre-counted
            'friends_count' => $this->whenCounted('friends'),
            'lists_count'   => $this->whenCounted('lists'),
            'reviews_count' => $this->whenCounted('reviews'),
            'posts_count'   => $this->whenCounted('posts'),
        ];
    }
}

when(condition, value) is Laravel’s conditional inclusion. When the condition is false, the key is completely absent from the JSON response — not null, not empty array, just absent. Closures as the value argument mean the code inside only executes when the condition is true. You’re not computing friend lists for guests; you’re not even attempting to load them.

The controller becomes almost trivial:

class ProfileController extends Controller
{
    public function show(string $username)
    {
        $user = User::where('username', $username)
            ->with(['lists', 'reviews', 'posts', 'comments',
                    'activities', 'friends', 'followedGroups', 'followedIdols'])
            ->withCount(['friends', 'lists', 'reviews', 'posts'])
            ->firstOrFail();

        return new UserResource($user);
    }
}

No permission checks. No conditional logic. No data transformation. Load the data and pass it to the resource. All the privacy decisions live in one place.

How whenLoaded() Prevents N+1 Queries

$this->whenLoaded('lists') checks if the relationship was eager-loaded before trying to include it. If you forgot to eager-load lists, it returns a MissingValue object — a special class that Laravel automatically removes from JSON output. The field won’t appear in the response at all.

This is important because it forces you to be explicit. You can’t accidentally trigger lazy loading of thousands of related records by forgetting a with() call:

// Without whenLoaded: accessing $user->lists triggers N separate queries
// if you have N posts, one per post
$users = User::all();
return UserResource::collection($users);  // Each resource lazy-loads lists

// With whenLoaded: if lists wasn't eager-loaded, the field just doesn't appear
// No query, no error, no surprise

Similarly, whenCounted('friends') only includes the count if you called withCount('friends') in the controller. These methods turn missing data into intentional omission rather than runtime errors.

Three Things That Break This Pattern

Not eager-loading. If you do User::find($id) without with('lists') but the resource calls whenLoaded('lists'), the lists key won’t appear. This looks like a bug but is actually correct behavior — the resource is telling you “you didn’t load this data, so I’m not including it.” The fix: always eager-load what your resource might need.

Circular references. If UserResource includes friends, and FriendshipResource includes the user, you get infinite recursion. The fix: create specialized minimal resources for nested data. FriendshipResource should use a UserBasicResource that includes only id, username, and avatar — not the full UserResource with all its relationships.

Assuming viewer exists. Guest users aren’t authenticated, so $viewer will be null. Always guard computed fields:

// Crashes for guest users
'is_friend' => $viewer->isFriendsWith($this->resource)

// Safe
'is_friend' => $this->when(
    $viewer !== null,
    fn() => $viewer->isFriendsWith($this->resource)
)

Testing the Privacy Rules

Testing is straightforward because all the logic is centralized:

class UserResourceTest extends TestCase
{
    public function test_guest_cannot_see_private_lists()
    {
        $user = User::factory()->create(['show_lists' => false]);
        $response = $this->getJson("/api/users/{$user->username}");
        $response->assertJsonMissing(['lists']);
    }
    
    public function test_owner_can_see_own_email()
    {
        $user = User::factory()->create();
        $response = $this->actingAs($user)->getJson("/api/users/{$user->username}");
        $response->assertJsonFragment(['email' => $user->email]);
    }
    
    public function test_friends_can_see_activity_when_enabled()
    {
        $user = User::factory()->create(['show_activity_tab' => true]);
        $friend = User::factory()->create();
        $user->sentFriendRequests()->create([
            'addressee_id' => $friend->id, 'status' => 'accepted'
        ]);
        $response = $this->actingAs($friend)->getJson("/api/users/{$user->username}");
        $response->assertJsonStructure(['activities']);
    }
}

Each test covers one privacy scenario. Because all the logic lives in UserResource and PrivacyService, the tests are testing the right thing — not controller boilerplate.

Resources are security boundaries, not just data transformers. Every field that crosses from your database to a client has to pass through a conscious decision about who’s allowed to see it. Centralizing that decision in the resource layer — rather than distributing it across controllers — is what makes that decision auditable and testable.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!