dmytroshved's avatar

Select top users based on their Recipes popularity

I am trying to get the top of users and order them by popularity of their recipes

The popularity of a recipe is determined by the number of:

  • likes

  • dislikes

    (likes and dislikes are stored in create_votes_table and showed in column vote with 1, -1, or 0 (like, neutral, dislike))

  • savings by users

    (savings are stored in the create_saved_recipes_table and simply stores recipe_id of recipe and user_id of user who saved this Recipe)

it receives. If a user has multiple recipes, these metrics are summed up to calculate the total number of likes, dislikes, and saves across all their recipes. For example, if one user has three recipes with 3, 2, and 5 likes, and another user has a single recipe with 15 likes, the second user will rank higher on the list than the first.

If the first recipe has more likes than the second, but the second has more saves, the second is considered more popular. This means that saves carry more weight in the popularity statistics.

User.php

    public function recipes(): HasMany
    {
        return $this->hasMany(Recipe::class);
    }

Recipe.php

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


    public function scopePopular(Builder $query): Builder
    {
        return $query->whereHas('votes', fn ($q) => $q->where('vote', 1))
            ->orWhereHas('savedByUsers')
            ->withCount([
            'votes as likesCount' => fn (Builder $query) => $query->where('vote', 1),
            'votes as dislikesCount' => fn (Builder $query) => $query->where('vote', -1),
            'savedByUsers as savedCount',
        ])
            ->orderByDesc('savedCount')
            ->orderByDesc('likesCount')
            ->orderBy('dislikesCount')
            ->orderByDesc('created_at');
    }

create_votes_table

        Schema::create('votes', function (Blueprint $table) {
            $table->id();
            $table->foreignId('recipe_id')->constrained()->cascadeOnDelete();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->smallInteger('vote');
            $table->timestamps();
        });

create_saved_recipes_table

        Schema::create('saved_recipes', function (Blueprint $table) {
            $table->id();
            $table->foreignId('recipe_id')->constrained()->cascadeOnDelete();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->timestamps();
        });

I tried this approach:

$authorsOfTheWeek = User::select(['id','name', 'photo'])->withCount([
    'recipes as recipe_popularity' => fn($query) => $query->popular()
])
    ->having('recipe_popularity', '>', 0)
    ->orderByDesc('recipe_popularity')
    ->get();

But now I can't order users by Recipe's popularity parameters

Would be grateful for your help

Best regards

1 like
7 replies
Tray2's avatar
Tray2
Best Answer
Level 73

I this case you are probably better off by doing it in plain SQL and not with the query builder.

What I would do is to use the likes/dislikes/saves and from them calculate a value, something like this.

  • Like = 1p
  • Dislike = -1p
  • Save = 2p

10 like - 2 dislikes + 5 saves =. 10 - 2 + 5 x 2 = 18

Then order on that score, and take the top ten.

1 like
dmytroshved's avatar

@Tray2 I am not well familiar with plain SQL inside Laravel, could you explain me where can I learn about it?

1 like
Glukinho's avatar

This doesn't answer your question, but there is another approach which may be profitable to your situation.

Make an integer column recipes.popularity and update it accordingly when things happen to the recipe (I mean, if a recipe is liked, increment the column by 1; if disliked, decrement by 1 etc)

Pros are:

  • getting top popular authors is easy
  • no complex queries to care of
  • no complex calculations every time you need to show popular authors (better if app load grows)
  • if your calculation logic changes in time (for example, you want to add 5 points for each like instead of 1) current popularities will be preserved and users will not see huge popularity change out of nowhere

Cons:

  • you should not forget to update the column in all cases it needs to be updated
  • you should take special care of "reverse" cases when a user gives a like and then gets it back, the column should be decreased back
  • it's only overall popularity of all time, it's not obvious to me how to implement "top authors of the week"
1 like
dmytroshved's avatar

After some google'ing and DeepSeek talks and questions I ended with that code:

        $authorsOfTheWeek = User::query()
                ->select([
                    'id',
                    'name',
                    'photo',
                ])
                ->selectSub(function ($query) {
                    $query->selectRaw(
                        'COUNT(CASE WHEN votes.vote = 1 THEN 1 END) * 1 + ' .
                        'COUNT(CASE WHEN votes.vote = -1 THEN 1 END) * 1 + ' .
                        'COUNT(saved_recipes.id) * 2'
                    )
                        ->from('recipes')
                        ->leftJoin('votes', 'votes.recipe_id', '=', 'recipes.id')
                        ->leftJoin('saved_recipes', 'saved_recipes.recipe_id', '=', 'recipes.id')
                        ->whereColumn('recipes.user_id', 'users.id');
                }, 'popularity_score')
                ->having('popularity_score', '>', 0)
                ->orderByDesc('popularity_score')
                ->limit(12)
                ->get();

or this one:

        $authorsOfTheWeek = User::select([
            'users.id',
            'users.name',
            'users.photo',
            DB::raw('COALESCE((
            SELECT
                COUNT(CASE WHEN vote = 1 THEN 1 END) * 1 +
                COUNT(CASE WHEN vote = -1 THEN 1 END) * -1 +
                COUNT(saved_recipes.id) * 2
            FROM recipes
            LEFT JOIN votes ON votes.recipe_id = recipes.id
            LEFT JOIN saved_recipes ON saved_recipes.recipe_id = recipes.id
            WHERE recipes.user_id = users.id
        ), 0) AS popularity_score')
        ])
            ->having('popularity_score', '>', 0)
            ->orderByDesc('popularity_score')
            ->limit(12)
            ->get();

It works fine and perfomance is very good

What do you think? @glukinho @tray2 ?

Now wanna try to use db view from @tray2

Tray2's avatar

@Dmytro_Shved Either would work fine, however COUNT(DISTINCT saved_recipes.id) * 2 looks a bit off to me. It implies that even if several users has saved the recipe, it will only be counted once, not for every user. However, that might be the way you want it.

1 like

Please or to participate in this conversation.