On this page
- Purpose
- The Problem
- The Solution
- What You’ll Learn
- Prerequisites & Tooling
- Knowledge Base
- Environment
- Setup Commands
- High-Level Architecture
- The Data Flow
- Smart Photo Album
- The Implementation
- Understanding the Naive Approach
- The Resource Pattern Foundation
- Adding the Privacy Service
- Understanding Conditional Inclusion
- The Complete Privacy-Aware Resource
- Using the Resource in Controllers
- Under the Hood
- How Laravel Resources Work Internally
- The Magic of when() and whenLoaded()
- Memory Considerations
- Performance: The N+1 Query Problem
- Edge Cases & Pitfalls
- Forgetting to Eager Load
- Circular References
- When to Use Resources vs. Transformers
- Always Validate Viewer Context
- Testing Privacy Logic
- Conclusion
- What You’ve Learned
- The Key Insight
- Next Steps
- Real-World Application
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
MissingValuewhich 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:
- Separate concerns: Privacy logic lives in a dedicated service, not scattered across controllers
- Prevent security bugs: Centralized permission checks mean you can’t forget to check permissions
- Optimize performance:
whenLoaded()prevents N+1 queries by forcing explicit eager loading - 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
UserBasicResourcefor 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. 🎉