Weighted score & trending
Two ranking scopes go beyond raw counts:
Post::orderByLikeScore()->get(); // rank by a weighted sum of reactions (desc)
Post::orderByTrending()->get(); // rank by recency-weighted activity (desc)
Post::orderByLikeScore('asc')->get();
Post::orderByTrending('desc', 'love')->get(); // trending lovesWeighted score
orderByLikeScore() ranks by a weighted sum of reactions from config('likes.weights'). With the default (no weights) the score equals the raw like count. Give some reactions more pull:
// config/likes.php
'weights' => ['like' => 1, 'love' => 4],
'default_weight' => 1,Once weights is non-empty, any reaction not listed uses default_weight.
Trending
orderByTrending() scores each row as recent weighted activity × recent_multiplier + all-time weighted activity, where recent means created inside trending.window. The window cut-off is computed in PHP and bound as a parameter, and every weight is bound too — so the SQL stays portable across SQLite, MySQL and PostgreSQL, and injection-safe.
// config/likes.php
'trending' => [
'window' => '3 days', // what counts as "recent"
'recent_multiplier' => 5, // recent likes weigh 5x on top of all-time activity
'driver_expressions' => [],
],Reading the score
Each scope selects its score onto the row — like_score or trending_score — so you can show it or debug the order:
$top = Post::orderByLikeScore()->first();
$top->like_score; // the weighted score the rows were ranked by
$hot = Post::whereNotLikedBy($user)->orderByTrending()->limit(10)->get();
$hot->first()->trending_score;Both scopes accept a direction (asc or desc — anything else sorts descending) and an optional reaction type, ignore unliked rows, and compose with other scopes.
Per-driver expressions
Hosts that want an exact decay curve can replace the trending score per database driver with a raw SQL aggregate over the likes rows. orderByTrending() uses it verbatim for queries on a connection of that driver — the portable hybrid for any other — and every ? in it is bound to the window cut-off (now minus trending.window):
// config/likes.php
'trending' => [
'window' => '7 days',
'recent_multiplier' => 3,
'driver_expressions' => [
// Gravity: each like decays with its age in hours.
'pgsql' => 'SUM(1 / POWER(EXTRACT(EPOCH FROM (NOW() - created_at)) / 3600 + 2, 1.8))',
// Recent likes count 10x, bound to the window cut-off.
'sqlite' => 'SUM(CASE WHEN created_at >= ? THEN 10 ELSE 1 END)',
],
],The expression is host configuration, never user input — it is placed into the query as written. TrendingScore::trending() resolves the same expression for your own ranking queries:
use RoundlyConsulting\Likes\Support\TrendingScore;
['expression' => $expression, 'bindings' => $bindings] = TrendingScore::trending(); // the likes model's driver
['expression' => $expression, 'bindings' => $bindings] = TrendingScore::trending('pgsql');
// your driver_expressions entry for that driver (every ? bound to the window cut-off)
// when one is configured, otherwise the portable expressionShow your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.