Some extra context on "it can get more complex" and why I'm afraid this is a code smell: A Policy is quite a low-level and self-contained context, and sometimes it can be hard to have all the information available - especially if you need data from related models.
Example from the same app: The rules around deleting User
models are more complex. A User
can have one or more roles: admin
, tutor
(each class can have one tutor), and student
(students can be enrolled in classes).
The User
model then has two Eloquent relationships for querying information about the tutor and student roles:
/**
* One-to-many relationship between a (tutor) user and classes they tutor.
*/
public function classesTutored(): HasMany { /* ... */ }
/**
* Many-to-many relationship between (student) users and the classes in which they are enrolled.
*/
public function classesTaken(): BelongsToMany { /* ... */ }
The (simplified) rules for deleting a target User
model are:
admin
.'classesTutored'
relationship must have a count
of zero.'classesTaken'
relationship must have a count
of zero.A naive implementation of this in the UserPolicy
would simply query the relationships:
public function delete(User $user, User $target): bool
{
return $user->isAdmin()
&& $target->classesTutored()->count() === 0
&& $target->classesTaken()->count() === 0;
}
However, this does two separate DB requests. A Policy is (IMO) the wrong place for DB requests - I would usually want to make it efficient by eager-loading all the user data at the same time. So by the time it gets to checking the policy, the relevant count
data may well already be available. But that may also happen in different ways. Taking the 'classesTutored
' relation, for example:
->withCount('classesTutored')
, in which case it's available in a classes_tutored_count
property on the target User
model.->with('classesTutored')
, in which case the count is available (without hitting the DB) through $target->classesTutored->count()
.I've created a trait called SmartCount
to automatically cater for these possibilities. The trait is applied to any Model where it's needed (in this case, User
). Then the UserPolicy
can handle deletes efficiently like this:
public function delete(User $user, User $target): bool
{
return $user->isAdmin()
&& $target->smartCount('classesTutored') === 0
&& $target->smartCount('classesTaken') === 0;
}
The trait itself is:
trait SmartCount
{
/**
* Implements a smart count algorithm for the given relation,
* only hitting the DB if necessary.
*/
public function smartCount(string $relation): int
{
$prop = Str::snake($relation).'_count';
return match (true) {
/* Scenario 1: The count has been loaded using withCount('relation'). */
isset($this->$prop) => $this->$prop,
/* Scenario 2: The collection has been eager-loaded using with('relation'). */
$this->relationLoaded($relation) => $this->$relation->count(),
/* Scenario 3: The count is not available, so a DB request is required. */
default => $this->$relation()->count(),
};
}
}
Again, I'd love any expert opinions on this approach - and if it's too clever for it's own good, what a "better" approach may be.
Sign in to participate in this thread!
The Laravel portal for problem solving, knowledge sharing and community building.
The community