Performance
This page explains the query-cost model behind Laravel Like and shows how to keep it fast at scale.
N+1 guarantees
The package is designed to avoid per-row queries:
| Operation | Query count | Type |
|---|---|---|
isLiked() / isDisliked() / isLoved() | 1 per call | EXISTS |
likesCount() / dislikesCount() / lovesCount() | 1 per call | COUNT |
totalCount() (facade) | 1 per call | COUNT |
like() / dislike() / love() | 1 (up to 3) | find + maybe delete + create — transaction |
unlike() / unDislike() / unlove() | 1 per call | find + delete — transaction |
toggle() | 1 | find + (create / delete / update) |
userInteractions() | 1 | SELECT all rows |
userLikedModels() | 1 + N (N = distinct model types) | query the likes, then one per type |
likeCountsFor($models, $type) | 1 + N (N = distinct model types in the collection) | one COUNT ... GROUP BY model_id per type |
userInteractionsFor($models, $userId) | 1 + N (N = distinct model types in the collection) | one SELECT per type, keyed by model |
None of these scale with the number of rows. Whether a user has 1 interaction or 10 000, isLiked() always costs exactly one query.
Wrong way: loop calling model helpers
Calling likesCount() in a loop will produce one COUNT query per item:
// ❌ BAD — one COUNT per post
$posts = Post::limit(50)->get();
foreach ($posts as $post) {
$count = $post->likesCount(); // N+1 query!
}
Right way: eager-load counts
// ✅ GOOD — one query total
$posts = Post::withCount([
'likesTo as likes_count',
'dislikesTo as dislikes_count',
'lovesTo as loves_count',
])->limit(50)->get();
foreach ($posts as $post) {
echo $post->likes_count; // already loaded
}
Wrong way: loop calling isLiked() / isDisliked() / isLoved()
Each predicate is one EXISTS query — cheap for a single model, an N+1 for a list:
// ❌ BAD — one EXISTS per post
$posts = Post::limit(50)->get();
foreach ($posts as $post) {
$liked = $post->isLiked(); // N+1 query!
}
Right way: batch the current user's state with userInteractionsFor()
// ✅ GOOD — one query total
use CSlant\LaravelLike\Facades\Like;
$posts = Post::limit(50)->get();
$interactions = Like::userInteractionsFor($posts, auth()->id());
foreach ($posts as $post) {
$key = $post->getMorphClass().':'.$post->getKey();
$liked = $interactions->get($key)?->type->isLike() ?? false;
}
See userInteractionsFor() for the full reference.
Eager-loading interaction relationships
The likes() relation is a plain MorphMany. Standard Laravel eager loading applies:
// Eager-load all interactions on a model
$post = Post::with('likes')->first();
foreach ($post->likes as $interaction) {
echo $interaction->type; // cast to InteractionTypeEnum
echo $interaction->user; // resolve the user too?
}
To eager-load the user as well:
$post = Post::with('likes.user')->first();
To eager-load the interacted model starting from User:
$user = User::with('likes.model')->find(1);
foreach ($user->likes as $like) {
$like->model; // Post, Video... resolved in bulk
}
userLikedModels() query cost
This method is intentionally bounded:
- One
SELECTon thelikestable filtered to the user + typelike. - Group the results by
model_type(in-memory). - One
SELECTper distinct model type:SELECT ... WHERE id IN (…). - Combine into a single
Collection.
That's 1 + (distinct model types) queries. It does not grow with the number of liked items. For a user who liked 500 posts, it's still exactly 2 queries — not 500.
Caching counts
Even single-count queries can be expensive on very hot pages (a viral post, a trending feed). The package has built-in caching for likesCount() / dislikesCount() / lovesCount() — turn it on in config, no manual event wiring needed:
'cache' => [
'enabled' => true,
'ttl' => 300, // seconds
],
$post->likesCount(); // first call: 1 COUNT query, then cached
$post->likesCount(); // subsequent calls within the TTL: 0 queries
The cache is automatically invalidated by like(), dislike(), love(), unlike(), unDislike(), unlove(), and toggle() — you never get a stale count after a write made through the package. See Configuration for details.
totalCount() and the batched likeCountsFor() are not covered by like.cache.enabled — they're already single/bounded queries. If you write directly to the likes table bypassing the package (raw SQL, another service), the package cache won't know to invalidate; keep the TTL as a safety net for that case.
If you need to cache something the package doesn't (a computed leaderboard, likeCountsFor()'s result, totalCount()), a manual Cache::remember() still works the same way it does for any Eloquent query:
use Illuminate\Support\Facades\Cache;
use CSlant\LaravelLike\Facades\Like;
function hotPostTotalCount(Post $post): int
{
return Cache::remember("post:{$post->id}:total_count", 300, fn () => Like::totalCount($post));
}
Database indexes
The default migrations create two indexes on the likes table out of the box:
UNIQUE (user_id, model_id, model_type, type) -- create_likes_table migration
INDEX (model_type, model_id, type) -- add_type_lookup_index_to_likes_table migration
The unique index serves user-scoped lookups (isLiked(), findInteractionByType(), userInteractionsFor()). The composite index serves the counts that are not scoped to a user (likesCount(), dislikesCount(), lovesCount(), likeCountsFor()). Both are published automatically by vendor:publish --tag=migrations — see Installation.
For very large tables with heavy userInteractions() or admin-side bulk-user queries, you may still want an extra index the package doesn't ship by default:
public function up(): void
{
Schema::table('likes', function (Blueprint $table) {
// For userInteractions() or bulk user queries filtered by type
$table->index(['user_id', 'type']);
});
}
Batch aggregation for lists and dashboards
Use likeCountsFor() instead of calling likesCount() per item — it runs one query per distinct model type in the collection, not one per row:
use CSlant\LaravelLike\Facades\Like;
use CSlant\LaravelLike\Enums\InteractionTypeEnum;
use App\Models\Post;
$posts = Post::whereIn('id', $postIds)->get();
$counts = Like::likeCountsFor($posts, InteractionTypeEnum::LIKE);
// $counts is now [1 => 12, 3 => 47, ...] — keyed by model primary key
For ad-hoc analytics where you don't already have a Collection of models (e.g. you only have raw IDs), querying the Like model directly is equally cheap:
use CSlant\LaravelLike\Models\Like;
use App\Models\Post;
$counts = Like::query()
->where('model_type', Post::class)
->where('type', 'like')
->whereIn('model_id', $postIds)
->selectRaw('model_id, count(*) as total')
->groupBy('model_id')
->pluck('total', 'model_id');
Next Steps
- The LikeManager & Facade API — full method reference
- Counting interactions — aggregation recipes
- Query scopes — building reusable scopes