thezeroday/laravel-likeable
A Laravel package that adds likeable behavior to Eloquent models. Attach likes from users or any model, toggle and count likes, check whether a record is liked, and query liked items through simple traits and relationships, with migrations included.
Install the package
composer require thezeroday/laravel-likeable
Pin the version explicitly ("thezeroday/laravel-likeable": "^x.y") — with no dependents and a single release (2024-03-13), you want your lockfile to be the source of truth.
Publish and run migrations (most likeable packages ship a likes table with a polymorphic likeable relation):
php artisan vendor:publish --provider="Thezeroday\Likeable\LikeableServiceProvider"
php artisan migrate
If vendor:publish lists nothing, check src/ first — the migration may be auto-loaded and the config file may not exist at all.
Where to look first — in order:
src/Traits/ (or src/Concerns/): the trait you add to your models is the entire public API surface.src/Models/Like.php: reveals the polymorphic columns (likeable_type, likeable_id, user_id) and any unique index.config/ or the service provider's mergeConfigFrom: tells you whether the user model, table name, or morph alias is configurable.First use case — add the trait to any model that can receive likes:
use Thezeroday\Likeable\Traits\Likeable;
class Post extends Model
{
use Likeable;
}
Then from a controller:
public function like(Post $post)
{
$post->like(); // or $post->like(auth()->user())
return back();
}
You now have the three calls that cover 90% of real usage: like(), unlike() (or dislike()), and isLiked().
Toggle endpoint (the pattern you'll actually ship). Rather than separate like/unlike routes, expose one idempotent toggle and let the client stay dumb:
public function toggle(Post $post)
{
$post->toggleLike(); // or: $post->isLiked() ? $post->unlike() : $post->like();
return response()->json([
'liked' => $post->fresh()->isLiked(),
'count' => $post->fresh()->likesCount(), // adjust to actual method name
]);
}
Return the count in the response so the front end doesn't need a second round trip.
Always authorize before liking. The package will happily attach a like for whoever you pass in, so the guard belongs in your controller or a policy:
Gate::authorize('like', $post); // e.g. block liking your own content
Never render a list without eager-loaded counts. A feed of 25 posts that calls a count accessor per row is 25 extra queries:
$posts = Post::withCount('likes')
->with(['likes' => fn ($q) => $q->where('user_id', auth()->id())])
->latest()
->paginate(25);
Then in Blade:
<span>{{ $post->likes_count }}</span>
<button class="{{ $post->likes->isNotEmpty() ? 'liked' : '' }}">♥</button>
"Most liked" ordering falls straight out of withCount:
Post::withCount('likes')->orderByDesc('likes_count')->take(10)->get();
Compose with a policy and a domain event. If the package fires an event on like (check src/Events/), listen to it for notifications or activity feeds instead of stuffing that logic into the controller. If it doesn't, wrap the call in your own service method — that's your extension point and keeps you insulated if the package is abandoned.
Blade component. Wrap the button + count in a single <x-like-button :model="$post" /> so every place in the app gets the same markup, the same route, and the same optimistic-JS hook.
API resources. Put liked and likes_count in the resource, not in the model's $appends — appending globally re-introduces the N+1 you just fixed.
src/Traits/ end to end (it's small), and be prepared to fork. Commit a note in your README naming the fork point.Relation::enforceMorphMap(), make sure the likeable models' aliases match what's already in the likes.likeable_type column. A mismatch silently returns zero likes rather than throwing.unique(['likeable_type', 'likeable_id', 'user_id']). If it doesn't, you'll get duplicate likes under double-click or concurrent requests — add the index in your own migration. If it does, handle the resulting QueryException (or wrap like() in a firstOrCreate-style check) so a race returns 200 rather than 500.onDelete('cascade') in your own migration if needed.isLiked() and query cost. These packages typically check the loaded relation if present, otherwise hit the DB. In a loop that means N queries — hence the eager-loading pattern above. Verify by logging queries in a test.cache()->remember), invalidate on the like event, not on the page render. Toggling twice quickly with a stale TTL is the classic bug report.RefreshDatabase, assert both the row and the returned state:
$this->actingAs($user)->post("/posts/{$post->id}/like")->assertOk();
$this->assertDatabaseHas('likes', [
'likeable_id' => $post->id,
'likeable_type' => $post->getMorphClass(),
'user_id' => $user->id,
]);
Use getMorphClass() rather than a hardcoded string — it survives a morph map change.App\Concerns\HasLikes) so a future swap to a maintained package is a one-file change. Also add a smoke test that just calls like(), unlike(), and the count accessor — that's your early warning if a Laravel upgrade breaks the package.How can I help you explore Laravel packages today?