Support the ongoing development of Laravel.io →
Authorization Laravel Validation
0

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:

  1. The logged-in user must be an admin.
  2. The target user must not be a tutor for any classes - that is, their 'classesTutored' relationship must have a count of zero.
  3. The target user must not be enrolled as a student in any classes - that is, their '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:

  • The count may have been retrieved along with the target user using ->withCount('classesTutored'), in which case it's available in a classes_tutored_count property on the target User model.
  • The entire collection may have been retrieved using ->with('classesTutored'), in which case the count is available (without hitting the DB) through $target->classesTutored->count().
  • Otherwise, a DB request will be necessary.

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.

0

Sign in to participate in this thread!

Eventy

Your banner here too?

Moderators

We'd like to thank these amazing companies for supporting us

Your logo here?

Laravel.io

The Laravel portal for problem solving, knowledge sharing and community building.

© 2024 Laravel.io - All rights reserved.