On this page
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.