turahe/laravel-likeable
Laravel Likeable adds likes and dislikes to any Eloquent model in minutes. Prepare your model, then use built-in methods, scopes, events, and console commands to manage reactions with clean, tested Laravel integration.
Install and publish the schema.
composer require turahe/laravel-likeable
php artisan vendor:publish --provider="Turahe\Likeable\LikeableServiceProvider" --tag=migrations
php artisan migrate
The tag name matters — it's migrations, not likeable-migrations. Before running migrate, open the published file(s) and note the table names, the polymorphic columns (likeable_type, likeable_id), and the counters table. You'll reference these in raw queries and reports later, so it's worth 60 seconds now.
Wire up your first model.
use Turahe\Likeable\Contracts\Likeable as LikeableContract;
use Turahe\Likeable\Traits\Likeable;
class Article extends Model implements LikeableContract
{
use Likeable;
}
Implement the contract, not just the trait. The contract is what the service layer and container bindings type-hint against; a model with only the trait will work in simple cases but break as soon as the service resolves it or you swap in a custom implementation.
First use case: a toggle endpoint.
Route::post('/articles/{article}/like', function (Article $article) {
$article->likeToggle();
return back();
});
<span>{{ $article->likesCount }}</span>
@if($article->liked) Liked @endif
That's the whole happy path — no pivot tables, no custom controller logic, no user_id juggling. The current user comes from auth().
Where to look first when you need to know more:
Turahe\Likeable\Traits\Likeable — the actual surface area. Read it before trusting the README; magic accessors (likesCount, liked) are implemented as getXAttribute and hasX/scopeX methods, and the trait is the ground truth.Turahe\Likeable\Services\LikeableService — "most of the logic lives here." This is where toggle semantics, mutual exclusivity, and counter updates are decided.Turahe\Likeable\Contracts\* — Like, LikeCounter, LikeableService. These are the extension points.Turahe\Likeable\Events\* — four events, one per state change.LikeType enum (referenced in the test suite as "Enum Tests") — used by the recount command's --type flag.Controllers stay thin; the trait does the state work. Resist the urge to write if ($article->liked()) { $article->unlike(); } else { $article->like(); }. likeToggle() and dislikeToggle() already encode that plus the mutual-exclusivity rule (a model can't be both liked and disliked by the same user). Each toggle fires the appropriate event exactly once, which you'll want for listeners.
Guard for guests at the edge of the request. Every no-argument method (like(), dislike(), liked, disliked) resolves the current user via auth. Put the guest check in middleware or a form request rather than scattering auth()->check() around:
Route::middleware('auth')->group(function () {
Route::post('/articles/{article}/like', [LikeController::class, 'like']);
Route::post('/articles/{article}/dislike', [LikeController::class, 'dislike']);
});
Always eager-load counters on index pages. This is the single biggest performance lever:
$articles = Article::with('likesCounter', 'dislikesCounter')
->orderByLikesCount()
->paginate(20);
Without with(), each $article->likesCount access is another query and you've got a classic N+1 on a listing page. The README explicitly calls out with('likesCounter') inside the scope examples for this reason.
Compose feed queries with the scopes rather than hand-writing whereHas.
// "Articles I've liked"
Article::whereLikedBy(auth()->id())->with('likesCounter')->get();
// "Most liked"
Article::orderByLikesCount()->get();
Article::orderByDislikesCount('asc')->get();
// "Controversial" — use the diff accessor for the ranking, sort in PHP or
// add a generated column if this becomes a hot path
$articles->sortByDesc->likesDiffDislikesCount;
whereLikedBy / whereDislikedBy produce a subquery against the likes table; they compose fine with your own where/with/paginate calls.
Use the liked() method, not the liked property, inside loops. The property is convenient in Blade for a single record, but liked($user->id) gives you deterministic behaviour and — importantly — the docs note it checks eager-loaded likes / likesAndDislikes relations first. If you're rendering a list for a logged-in user and want to avoid a query per row, eager-load the relation and then ask:
$articles = Article::with(['likes', 'likesCounter'])->get();
// per row, liked() consults the loaded collection instead of hitting the DB
Listen to events instead of monkey-patching the trait. Notifications, cache invalidation, gamification points, activity-log entries — all belong in listeners:
Event::listen(ModelWasLiked::class, function (ModelWasLiked $event) {
Cache::forget("article:{$event->likeable->id}:stats");
AwardPoints::dispatch($event->likeable->author_id ?? null, 1);
});
Verify the event payload shape in Turahe\Likeable\Events\* before writing listeners — the README names the events but doesn't document their properties.
Expose reaction state through an API resource, computed once per request.
class ArticleResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'likes' => $this->likesCount,
'dislikes' => $this->dislikesCount,
'score' => $this->likesDiffDislikesCount,
'my_reaction'=> $this->liked() ? 'like' : ($this->disliked() ? 'dislike' : null),
];
}
}
Pair it with a with('likesCounter', 'dislikesCounter') on the controller query and the whole collection serialises in a constant number of queries.
Swap in custom models when you need richer behaviour. The Like model is bindable, which is the escape hatch for soft deletes, extra columns, or an activity feed:
// AppServiceProvider::register()
$this->app->bind(
\Turahe\Likeable\Contracts\Like::class,
\App\Models\CustomLike::class
);
CustomLike must implement Turahe\Likeable\Contracts\Like. Do the same for LikeCounter if you need per-period counters, and for LikeableService (bound as a singleton, per the README) if you need different toggle semantics. Note the singleton: if your custom service holds per-request state, register it as bind instead, or make it stateless.
Schedule the recount as a safety net. php artisan likeable:recount re-derives counters from the source rows. If you ever write to the likes tables outside the package (bulk imports, data fixes, direct SQL), schedule a nightly recount for the affected models:
$schedule->command('likeable:recount --model="article"')->daily();
Test with the package's own migrations. In your test suite, ensure the published migrations run under RefreshDatabase, then assert on both the fluent API and the counter columns:
$article->like($user->id);
$this->assertSame(1, $article->fresh()->likesCount);
$this->assertTrue($article->liked($user->id));
Use fresh() — counters are updated through the service and the in-memory model may not reflect them without a reload.
Watch the attribute/relation naming split. likesCount is a scalar attribute; likesCounter is a relation (eager-loadable); likes is the full relation. Mixing them up is the most common source of silent N+1s: with('likesCount') will silently do nothing, whereas with('likesCounter') works. The same trio exists on the dislike side.
The "current user" magic is implicit and unauditable. $article->like() reads auth(). That's great for controllers and terrible inside queued jobs, Artisan commands, or tests without an authenticated user. In any non-HTTP context, pass the user ID explicitly: $article->like($userId). And check what happens with no authenticated user — the README doesn't say, so trace LikeableService or wrap the call in a guard before shipping.
Mutual exclusivity is a feature, not a bug — but confirm the direction. The README states likes and dislikes for one model are mutually exclusive. It does not spell out whether like() on a previously-disliked model removes the dislike, or throws. Read LikeableService and write a test asserting the behaviour you need before building UI copy around it.
The counter columns are a cache, and caches drift. likesCount reads a stored counter, not a COUNT(*). Any direct DB manipulation (seeders, imports, manual delete() on the likes table) will desynchronise it. removeLikes() / removeDislikes() are the supported bulk-delete path — use them rather than $article->likes()->delete(). And verify whether bulk removal fires the ModelWasUnliked event; if it doesn't, don't rely on listeners for counter reconciliation.
The docs contradict themselves on the recount command signature. The Features section advertises likeable:recount {model?} {type?} (positional arguments); the Console Commands section uses --model= and --type= options. Run php artisan likeable:recount --help once and trust that output, not the README.
Morph map aliases leak into likeable_type. The recount command accepts either an alias (--model="article") or an FQCN. If you introduce a morph map after data exists, existing rows will hold FQCNs while new rows hold aliases, and whereLikedBy / recount filters will see only half your data. Decide on the morph map before the first like is recorded, or write a backfill migration.
PHP and Laravel floors are enforced at install time. PHP 8.3+ (8.3 / 8.4 / 8.5 badges) and Laravel 10–13. If your app is on PHP 8.2 or Laravel 9, Composer will refuse — no graceful degradation. The README's "Laravel 5.4 and lower" service-provider registration instructions are stale leftovers; ignore them on any modern install.
Adoption risk is real. Nine stars and zero dependents means very little third-party battle-testing. For a production feature: pin the exact version in composer.json, read CHANGELOG.md before every bump (the project uses conventional commits and automated releases, so minor versions may carry behavioural changes), and consider writing a thin wrapper layer in your app around the trait's methods so that swapping to a DIY implementation or a more established alternative stays cheap.
Don't edit the published migration. If you need extra columns (e.g. a reaction_note, soft deletes on likes), add a separate Schema::table(...) migration and extend the Like model via the container binding. Editing the package migration in place means it silently diverges from what the maintainer ships, and the next vendor:publish — or a fresh CI database — produces a different schema.
Debugging tactics, in order of usefulness:
DB::listen() to catch the N+1 you almost certainly introduced on the index page.Event::fake() in tests to assert that exactly one ModelWasLiked fired per likeToggle() — catches double-dispatch from sloppy toggle logic.php artisan likeable:recount against a staging DB when counts "look wrong"; if the recount fixes the numbers, your writes are bypassing the package.How can I help you explore Laravel packages today?