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

Purpose

The Problem

Imagine you’re building a social platform where users can create profiles, posts, and lists. User Alice wants to share her favorite K-pop songs publicly, but keep her comments private. User Bob wants friends to see his activity feed, but not strangers. User Carol wants everything public.

The naive approach? Check permissions in every controller method, scatter conditional logic across your codebase, and pray you don’t forget a check somewhere. This leads to:

  • Security vulnerabilities when you forget a permission check
  • Inconsistent behavior across different API endpoints
  • Maintenance nightmares when privacy rules change
  • Bloated controllers with repetitive authorization logic

The Solution

We’re analyzing the UserResource class from MyKpopLists, which demonstrates how to build privacy-aware API resources that automatically respect user permissions without cluttering your controllers. This pattern ensures that the data transformation layer (what gets sent to the frontend) is aware of who’s viewing it and what they’re allowed to see.

What You’ll Learn

This tutorial covers:

  • Laravel API Resources with conditional data inclusion
  • Service-based authorization patterns
  • Relationship eager loading optimization
  • The difference between authentication (who you are) and authorization (what you can see)

Prerequisites & Tooling

Knowledge Base

  • Basic PHP syntax and object-oriented programming
  • Understanding of Laravel models and relationships
  • Familiarity with REST API concepts
  • Basic understanding of authentication (logged-in vs. guest users)

Environment

Based on the project’s composer.json:

  • PHP: 8.2 or higher
  • Laravel: 12.x
  • Database: SQLite (development) or PostgreSQL (production)

Setup Commands

# Clone and setup
composer install
php artisan migrate
php artisan db:seed

# Run development server
php artisan serve

High-Level Architecture

The Data Flow

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
    K --> L[Frontend]

Smart Photo Album

Think of an API Resource like a smart photo album. When you show your photo album to different people, you might:

  • Show everything to your spouse
  • Hide embarrassing childhood photos from coworkers
  • Show only vacation photos to acquaintances

The album doesn’t change—but what each person sees depends on their relationship to you. Similarly, a User model doesn’t change, but the UserResource shows different data depending on who’s viewing it.

The Implementation

Understanding the Naive Approach

Let’s first look at what most developers do (and why it’s problematic):

// ❌ NAIVE APPROACH - Don't do this
class ProfileController extends Controller
{
    public function show(User $user)
    {
        $data = [
            'id' => $user->id,
            'username' => $user->username,
            'bio' => $user->bio,
        ];
        
        // Scattered permission checks
        if (auth()->check() && auth()->id() === $user->id) {
            $data['email'] = $user->email; // Only show own email
        }
        
        if ($user->show_activity_tab) {
            $data['activities'] = $user->activities;
        }
        
        // This gets messy fast with 10+ fields!
        
        return response()->json($data);
    }
}

Problems with this approach:

  • Permission logic mixed with data transformation
  • Easy to forget checks when adding new fields
  • Difficult to test in isolation
  • Repeated across multiple controllers

The Resource Pattern Foundation

Laravel Resources provide a transformation layer between your models and JSON responses. Here’s the basic structure:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class UserResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     * 
     * @param Request $request - The incoming HTTP request
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        // $this refers to the User model instance
        // $request contains info about who's making the request
        
        return [
            'id' => $this->id,
            'username' => $this->username,
            'bio' => $this->bio,
            // More fields will go here
        ];
    }
}

Adding the Privacy Service

Now we introduce a dedicated service to handle all privacy logic:

<?php

namespace App\Http\Resources;

use App\Services\PrivacyService;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class UserResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     */
    public function toArray(Request $request): array
    {
        // Get the authenticated user (viewer)
        $viewer = $request->user();
        
        // Get the user being viewed (profile owner)
        $profileUser = $this->resource; // $this->resource is the User model
        
        // Instantiate our privacy service
        $privacyService = app(PrivacyService::class);
        
        return [
            // Always visible fields
            'id' => $this->id,
            'username' => $this->username,
            'name' => $this->name,
            'bio' => $this->bio,
            'profile_picture' => $this->profile_picture,
            'created_at' => $this->created_at,
            
            // Conditionally included fields
            // The whenLoaded() method only includes data if the relationship
            // was eager loaded (prevents N+1 queries)
            'lists' => $this->when(
                $privacyService->canViewUserContent($viewer, $profileUser, 'lists'),
                fn() => UserListResource::collection($this->whenLoaded('lists'))
            ),
        ];
    }
}

Understanding Conditional Inclusion

The when() method is Laravel’s way of conditionally including data. Let’s break down the syntax:

// Syntax: $this->when(condition, value)

// Example 1: Simple boolean check
'email' => $this->when(
    $viewer && $viewer->id === $this->id,  // Condition: viewing own profile
    $this->email                            // Value: the email address
),

// Example 2: Using a closure for expensive operations
'friends' => $this->when(
    $privacyService->canViewFriendsList($viewer, $this),
    fn() => FriendshipResource::collection($this->whenLoaded('friends'))
    // The closure only executes if the condition is true
),

// Example 3: Computed values
'is_friend' => $this->when(
    $viewer !== null,  // Only for authenticated users
    fn() => $viewer->isFriendsWith($this->resource)
),

The Complete Privacy-Aware Resource

Here’s the full implementation from the MyKpopLists codebase:

<?php

namespace App\Http\Resources;

use App\Services\PrivacyService;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

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: Only if user allows list visibility
            'lists' => $this->when(
                $privacyService->canViewUserContent($viewer, $profileUser, 'lists'),
                fn() => UserListResource::collection($this->whenLoaded('lists'))
            ),

            // Reviews: Only if user allows review visibility
            'reviews' => $this->when(
                $privacyService->canViewUserContent($viewer, $profileUser, 'reviews'),
                fn() => ReviewResource::collection($this->whenLoaded('reviews'))
            ),

            // Posts: Only if user allows post visibility
            'posts' => $this->when(
                $privacyService->canViewUserContent($viewer, $profileUser, 'posts'),
                fn() => PostResource::collection($this->whenLoaded('posts'))
            ),

            // Comments: Only if user allows comment visibility
            'comments' => $this->when(
                $privacyService->canViewUserContent($viewer, $profileUser, 'comments'),
                fn() => CommentResource::collection($this->whenLoaded('comments'))
            ),

            // Activity: Only if user allows activity visibility
            'activities' => $this->when(
                $privacyService->canViewUserContent($viewer, $profileUser, 'activity'),
                fn() => UserActivityResource::collection($this->whenLoaded('activities'))
            ),

            // Friends: Only if user allows friends list visibility
            'friends' => $this->when(
                $privacyService->canViewFriendsList($viewer, $profileUser),
                fn() => FriendshipResource::collection($this->whenLoaded('friends'))
            ),

            // Following: Only if user allows following list visibility
            '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 USER ONLY =====
            
            // Friendship status: Only for logged-in users
            'friendship_status' => $this->when(
                $viewer !== null,
                fn() => $viewer->getFriendshipStatus($profileUser)
            ),

            // Is friend: Only for logged-in users
            'is_friend' => $this->when(
                $viewer !== null,
                fn() => $viewer->isFriendsWith($profileUser)
            ),

            // ===== OWNER ONLY =====
            
            // Email: Only visible to the profile owner
            'email' => $this->when(
                $viewer && $viewer->id === $this->id,
                $this->email
            ),

            // Unread notifications: Only for own profile
            'unread_notifications_count' => $this->when(
                $viewer && $viewer->id === $this->id,
                fn() => $this->unreadNotifications()->count()
            ),

            // ===== STATISTICS =====
            
            // These are always visible but use whenCounted to avoid N+1
            'friends_count' => $this->whenCounted('friends'),
            'lists_count' => $this->whenCounted('lists'),
            'reviews_count' => $this->whenCounted('reviews'),
            'posts_count' => $this->whenCounted('posts'),
        ];
    }
}

Using the Resource in Controllers

Now your controllers become beautifully simple:

<?php

namespace App\Http\Controllers;

use App\Models\User;
use App\Http\Resources\UserResource;

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

        // The resource handles all privacy logic
        return new UserResource($user);
    }
}

Notice what’s NOT in the controller:

  • No permission checks
  • No conditional logic
  • No data transformation
  • Just load data and pass to resource

Under the Hood

How Laravel Resources Work Internally

When you return a Resource from a controller, Laravel performs these steps:

// 1. Your controller returns the resource
return new UserResource($user);

// 2. Laravel's HTTP kernel calls toResponse()
$resource->toResponse($request);

// 3. toResponse() calls toArray()
$data = $resource->toArray($request);

// 4. Laravel serializes to JSON
return response()->json($data);

The Magic of when() and whenLoaded()

Let’s examine how these methods prevent common pitfalls:

// whenLoaded() checks if a relationship was eager loaded
'lists' => UserListResource::collection($this->whenLoaded('lists'))

// Internally, Laravel checks:
if ($this->relationLoaded('lists')) {
    return $this->lists;  // Return the loaded data
} else {
    return new MissingValue();  // Special class that gets filtered out
}

Why this matters:

  • Prevents N+1 queries (won’t lazy load if you forgot to eager load)
  • Returns MissingValue which Laravel automatically removes from JSON
  • Forces you to be explicit about what data you need

Memory Considerations

// ❌ BAD: This loads ALL users into memory
$users = User::all();
return UserResource::collection($users);

// ✅ GOOD: Use pagination
$users = User::paginate(20);
return UserResource::collection($users);

// ✅ BETTER: Use cursor pagination for large datasets
$users = User::cursorPaginate(20);
return UserResource::collection($users);

Memory impact:

  • all(): Loads entire table into PHP memory (could be GBs)
  • paginate(): Loads 20 records (~10KB per record = 200KB)
  • cursorPaginate(): Same memory, but more efficient for large offsets

Performance: The N+1 Query Problem

// ❌ BAD: N+1 queries
$users = User::all();  // 1 query
foreach ($users as $user) {
    echo $user->lists->count();  // N queries (one per user)
}
// Total: 1 + N queries

// ✅ GOOD: Eager loading
$users = User::withCount('lists')->get();  // 1 query with JOIN
foreach ($users as $user) {
    echo $user->lists_count;  // No additional queries
}
// Total: 1 query

Edge Cases & Pitfalls

Forgetting to Eager Load

// Controller
$user = User::find($id);  // Forgot to load 'lists'
return new UserResource($user);

// Resource
'lists' => UserListResource::collection($this->whenLoaded('lists'))
// Result: 'lists' key won't appear in JSON (silently missing)

Solution: Always eager load relationships you plan to use:

$user = User::with('lists')->find($id);

Circular References

// UserResource includes friends
'friends' => FriendshipResource::collection($this->whenLoaded('friends'))

// FriendshipResource includes users
'user' => new UserResource($this->user)

// Result: Infinite loop! Stack overflow!

Solution: Create specialized resources for nested data:

// FriendshipResource uses UserBasicResource instead
'user' => new UserBasicResource($this->user)

// UserBasicResource only includes id, username, avatar

When to Use Resources vs. Transformers

Use Resources when:

  • Building REST APIs with consistent JSON structure
  • Need conditional field inclusion based on permissions
  • Want to leverage Laravel’s built-in pagination support

Use manual transformers when:

  • Building GraphQL APIs (use dedicated GraphQL libraries)
  • Need extreme performance (Resources add ~5ms overhead per request)
  • Transforming data for non-HTTP contexts (CLI commands, queues)

Always Validate Viewer Context

// ❌ DANGEROUS: Assuming viewer exists
'is_friend' => $viewer->isFriendsWith($this->resource)
// Crashes if $viewer is null (guest user)

// ✅ SAFE: Check for null
'is_friend' => $this->when(
    $viewer !== null,
    fn() => $viewer->isFriendsWith($this->resource)
)

Testing Privacy Logic

use Tests\TestCase;
use App\Models\User;
use App\Http\Resources\UserResource;

class UserResourceTest extends TestCase
{
    public function test_guest_cannot_see_private_lists()
    {
        $user = User::factory()->create([
            'show_lists' => false  // Private lists
        ]);
        
        // Make request as guest (no authentication)
        $response = $this->getJson("/api/users/{$user->username}");
        
        $response->assertJsonMissing(['lists']);
    }
    
    public function test_owner_can_see_own_email()
    {
        $user = User::factory()->create();
        
        // Authenticate as the user
        $response = $this->actingAs($user)
            ->getJson("/api/users/{$user->username}");
        
        $response->assertJsonFragment(['email' => $user->email]);
    }
    
    public function test_friends_can_see_activity_when_public()
    {
        $user = User::factory()->create(['show_activity_tab' => true]);
        $friend = User::factory()->create();
        
        // Create friendship
        $user->sentFriendRequests()->create([
            'addressee_id' => $friend->id,
            'status' => 'accepted'
        ]);
        
        $response = $this->actingAs($friend)
            ->getJson("/api/users/{$user->username}");
        
        $response->assertJsonStructure(['activities']);
    }
}

Conclusion

What You’ve Learned

You now understand how to build privacy-aware API resources that:

  1. Separate concerns: Privacy logic lives in a dedicated service, not scattered across controllers
  2. Prevent security bugs: Centralized permission checks mean you can’t forget to check permissions
  3. Optimize performance: whenLoaded() prevents N+1 queries by forcing explicit eager loading
  4. Scale gracefully: Adding new privacy rules requires changes in one place (PrivacyService)

The Key Insight

Resources are not just data transformers—they’re security boundaries. Every field that crosses from your database to the client should pass through a conscious decision about who can see it.

Next Steps

  • Practice: Add a new privacy setting (e.g., “hide favorite songs from non-friends”)
  • Extend: Create a UserBasicResource for nested relationships to avoid circular references
  • Optimize: Implement caching for privacy settings to reduce database queries
  • Test: Write comprehensive tests for all privacy scenarios

Real-World Application

This pattern is used by:

  • GitHub: Repository visibility (public/private/internal)
  • Twitter: Protected tweets and follower-only content
  • LinkedIn: Connection-only profile sections
  • Facebook: Complex friend lists and privacy zones

You’ve just learned a production-grade pattern used by billion-user platforms. 🎉

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!