CategoriesLaravel

Laraval Cyclical Relationships

Most of the time relationships are in Laravel are straight-forward. Things can become tricky when considering performance and cyclical relationships, however. We need to avoid both double-fetching the same data, and infinite loops.

Consider the following relationships where a User can create Blog Posts which belong to them.

class User extends Model {
    public function posts(): HasMany
    {
        return $this->hasMany(User::class);
    }
}

class Post extends Model {
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

Now let’s do something slightly unusual. Let’s say that when we output a post to the API, we need to check user information.

class Post extends Model {
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function toArray(): array
    {
        $data = parent::toArray();
        $data['should_highlight'] = (bool) $this->user->is_admin;
        return $data;
    }
}

If the user relationship is loaded, this is no big deal. If it isn’t, then we have to fetch the User model from the database for every post we render. This is a massive hit to performance, but we can improve it by eager-loading the relationship as part of the original query in the controller.

class PostController extends Controller {
    public function getPosts(): JsonResponse
    {
        $posts = Posts::orderBy('updated_at', 'DESC')
            ->limit(10)
            ->with('user') // Eager-load user relationship as 1 query.
            ->get();

        // Great performance. Two queries flat.
        return response()->json($posts->toArray());
    }
}

The above controller will execute two queries. One will fetch all the posts, then collect all the distinct user ids and run a second query to fetch all the users. Laravel will attach these User models to the Post models as we would expect.

Enter the Cyclic Relationship

What if we wanted to load all the posts a single user made? We know the user information already when loading their posts, so it would make sense that the reverse mapping would already be done for us. As such, the following example makes sense that it would “just work.”

class PostController extends Controller {
    public function getUserPosts(int $userId): JsonResponse
    {
        $user = User::whereId($userId)
            ->with('posts')
            ->firstOrFail();

        // Horrible N+1 performance.
        return response()->json($user->posts->toArray());
    }
}

Turns out Laravel isn’t that smart. We specified we wanted a user’s posts loaded, so $user->posts is loaded. But $user->posts[0]->user has to hit the database again.

The Official Way is Broken

Luckily, Laravel has the ability to manually set a relationship. Unfortunately for us, it doesn’t work in this case.

class PostController extends Controller {
    public function getUserPosts(int $userId): JsonResponse
    {
        $user = User::whereId($userId)
            ->with('posts')
            ->firstOrFail();

        // This will crash :(
        $user->posts->each->setRelation('user', $user);
        return response()->json($user->posts->toArray());
    }
}

When running the above code, PHP segfaults due to an infinite loop. Laravel doesn’t seem to like that we’re iterating on a model’s children and setting a property to the parent (which doesn’t make sense to me since all objects are passed by reference in PHP, but it is what it is).

The Strange (But Easy) Solution

We can load a model’s relationships with those relationship’s relationship to the parent (what a mouth-full). It’s much easier to see in an example than try to explain in English.

class PostController extends Controller {
    public function getUserPosts(int $userId): JsonResponse
    {
        $user = User::whereId($userId)
            ->with(['posts', 'posts.user']) // User -> Posts[] -> User.
            ->firstOrFail();

        // This works with two queries!
        return response()->json($user->posts->toArray());
    }
}

As weird as it feels to load a User model with a relationship which includes the relationship to itself, it works perfectly. The query is no different than the first one since Laravel recognizes we already have that User loaded. It wont go to the database to look anything extra up, and will set the property correctly.

This works on nested relationships as well, such as: $user->with(['posts', 'posts.comments', 'posts.comments.post']);.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.