Dynamic Eloquent Relationships: Binding Context to Data Models
In complex enterprise applications, data access is rarely one-size-fits-all. What an administrator sees when inspecting a user profile or an organization often differs drastically from what a regular customer, vendor, or guest can view. For instance, when loading an Order, an admin needs all of the transaction logs, internal audit notes, and payment gateway payloads, but a customer should only see customer facing order items and their public status updates.
Developers often solve this by writing messy if-else conditions in multiple controllers, or creating separate endpoints with duplicated logic. A much cleaner approach is to implement Dynamic / Conditional Relationships in Eloquent that can dynamically adjust based on a given runtime context like the authenticated user's role or active permissions
In this tutorial, we will learn how to construct context aware dynamic relationships in Laravel, how to restrict the eager loading of related models based off of roles, and how to utilize model relationship methods dynamically while keeping your query layer clean and secure.
What are Conditional Relationships?
A standard Eloquent relationship defines a static link between tables, such as:
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
A conditional or context-aware relationship either:
- Applies runtime query constraints (such as filtering out internal or private records) based on the user's role.
- Dynamically alters which related model or table is queried based on context.
- Conditionally eager loads relationships using Laravel's
when()or authorization checks.
Method 1: Context-Aware Relationship Methods on the Model
Let's take a realistic example: a Ticket support system where support tickets contain both public replies and internal staff notes.
Instead of manually filtering notes in every controller, we can define a role-aware relationship method directly on the model:
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Ticket extends Model
{
/**
* Base relationship: All comments (unfiltered).
*/
public function allComments(): HasMany
{
return $this->hasMany(Comment::class);
}
/**
* Public comments visible to anyone.
*/
public function publicComments(): HasMany
{
return $this->hasMany(Comment::class)->where('is_internal', false);
}
/**
* Context-aware relationship that adjusts automatically based on viewer role.
*/
public function comments(?User $viewer = null): HasMany
{
$viewer ??= auth()->user();
$relation = $this->hasMany(Comment::class);
// If viewer is an admin or agent, return all comments including internal notes
if ($viewer && ($viewer->hasRole('admin') || $viewer->hasRole('agent'))) {
return $relation;
}
// Regular users or guests only get public comments
return $relation->where('is_internal', false);
}
}
Let's understand how this works:
- We pass an optional
$viewerparameter defaulting to the current authenticated user viaauth()->user().
- If the user has administrative privileges, the relationship returns unrestricted comments.
- For customers or guests, the relation automatically appends
->where('is_internal', false).
Method 2: Conditional Eager Loading with whenLoaded() and with()
Sometimes you do not want to filter results of a relationship, but prevent the whole relationship from being loaded if the current user does not have a required role.
Laravel allows you to use the when() method on the query builder to conditionally add eager loaded relationships:
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\Request;
class OrderController extends Controller
{
public function index(Request $request)
{
$user = $request->user();
$orders = Order::query()
->with('items')
->when($user->hasRole('admin'), function ($query) {
$query->with(['paymentGatewayLogs', 'auditTrails']);
})
->latest()
->paginate(15);
return response()->json($orders);
}
}
This ensures you don't execute heavy join queries for regular users who don't have permission to see that data in the first place.
Method 3: Constrained Eager Loading Based on Auth Context
When you need to eager load relationships on a collection of models while applying dynamic role-based constraints, pass a closure inside with():
namespace App\Http\Controllers;
use App\Models\Project;
use Illuminate\Http\Request;
class ProjectController extends Controller
{
public function show(Request $request, Project $project)
{
$user = $request->user();
// Eager load tasks, but restrict internal/draft tasks unless the user is a manager
$project->load([
'tasks' => function ($query) use ($user) {
if (! $user->hasRole('manager')) {
$query->where('status', '!=', 'internal_draft')
->where('is_private', false);
}
}
]);
return response()->json($project);
}
}
Method 4: Pairing with API Resources (JsonResource)
Applying conditional queries at the database layer is half the equation. The other half is ensuring the response serialization cleanly matches the user's role.
Laravel API Resources provide built-in conditional methods like whenLoaded() and when():
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TicketResource extends JsonResource
{
public function toArray(Request $request): array
{
$user = $request->user();
return [
'id' => $this->id,
'subject' => $this->subject,
'status' => $this->status,
'created_at' => $this->created_at,
// Automatically serializes only if eager loaded
'comments' => CommentResource::collection($this->whenLoaded('comments')),
// Conditionally expose internal diagnostics only to staff
'internal_diagnostics' => $this->when(
$user && $user->can('view-internal-diagnostics'),
$this->internal_system_info
),
];
}
}
Comparison of Approaches
| Pattern | Where It Lives | Best Use Case |
|---|---|---|
| Dynamic Model Method | Eloquent Model (Ticket::comments()) |
Domain-wide business logic where non-admins must never see private records. |
Query Builder when() |
Controller / Repository | Preventing heavy or unnecessary queries from executing for lower-tier roles. |
Constrained with(['relation' => fn]) |
Controller / Action Class | Customizing dataset filters based on specific route permissions. |
Resource whenLoaded() |
JsonResource Layer | Clean API presentation and omitting keys that were excluded from the query. |
Important Things to Remember
- Caveat with Eager Loading Dynamic Methods: when passing arguments to custom relationship methods (like $ticket->comments($admin), Laravel is unable to automatically resolve arguments inside standard, string-based eager loads (like Ticket::with('comments')). For mass eager loading across collections, use constrained eager loads: Ticket::with(['comments' => fn ($q) => ...]);
- Do Not Rely Solely on Frontend Filtering: never return sensitive relationship data in the response with the assumption that the frontend app will hide it. Always enforce constraints in the database query;
- Background Jobs and CLI Context: keep in mind that when running code inside queues or Artisan console commands, auth()->user() is going to return null. Always take care of null auth states when writing dynamic model methods by providing fallback or default arguments.
Conclusion
Context aware and conditional relationships help you develop secure and multi-tenant, but role based Laravel applications without using a bunch of authorization checks littering your codebase. By combining parameterized methods, query builder when conditions and API Resource transformations, we can separate the concerns and protect your data privacy.
Thank you for reading this article 😊
For any query, do not hesitate to comment 💬
Also Read :
Refactoring the Legacy Laravel App
-compressed.jpg)