hkp22/laravel-reactions
Add reactions such as likes, loves, and more to any Eloquent model in your Laravel app. The package offers polymorphic, customizable reaction types, simple model traits, and a clean API for attaching, toggling, and counting reactions. Great for posts, comments, and social features.
Install and scaffold the package in three commands:
composer require hkp22/laravel-reactions
php artisan vendor:publish --provider="Hkp22\LaravelReactions\LaravelReactionsServiceProvider"
php artisan migrate
The provider publishes a config/reactions.php (reaction-type registry) and a migration creating the polymorphic reactions table. Skim the config first — it's the single knob that controls which reaction types are legal, and every error you'll hit later ("invalid reaction type") is really a config lookup that missed.
Wire the two traits:
// app/Models/User.php
use Hkp22\LaravelReactions\Traits\Reacter;
class User extends Authenticatable
{
use Reacter;
}
// app/Models/Post.php
use Hkp22\LaravelReactions\Traits\Reactable;
class Post extends Model
{
use Reactable;
}
Your first use case — a "like" button — is a four-line controller:
public function react(Post $post)
{
auth()->user()->toggleReactionTo($post, 'like');
return back();
}
That's the whole vertical slice. If you can get toggleReactionTo returning true/false in a test, the rest of the package is variations on the same call.
React / unreact / toggle. The Reacter trait gives you three primitives. toggleReactionTo() is what you want for UI buttons because it's idempotent from the caller's perspective — a double-click can't create duplicate rows if your migration has the right unique index. reactTo() and unreactTo() are for explicit actions (e.g., a moderation tool that force-removes a reaction).
Gate rendering on state. Before drawing a button, check whether the current user already reacted so you can render the "active" style:
$reacted = auth()->user()->hasReactedTo($post, 'like');
For a page listing many posts, do not call this per row inside Blade. Instead eager-load and use withCount with a filtered closure:
$posts = Post::withCount([
'reactions as likes_count' => fn ($q) => $q->where('reaction_type', 'like'),
'reactions as dislikes_count' => fn ($q) => $q->where('reaction_type', 'dislike'),
])->paginate(20);
Then derive the per-user state either from a second query (->whereIn('reactable_id', $posts->pluck('id')) on the reactions relation for auth()->id()) or cache it. This is the single biggest performance pattern with any polymorphic reaction table.
Aggregate summaries for detail pages. reactionSummary() returns counts keyed by type and is fine for a single post. Cache the result (Redis, 60s) on high-traffic pages — it's a GROUP BY on a table that grows fast.
Polymorphic by design. Add Reactable to Comment, Video, Review and the same reactTo() call works. The trait stores reactable_type + reactable_id, so no schema change is needed when you add a new reactable model. This is where the package earns its keep versus a hand-rolled likes table.
Custom reaction types. The config maps a string key to a reaction class:
// config/reactions.php
'reactions' => [
'like' => \Hkp22\LaravelReactions\Reactions\Like::class,
'love' => \Hkp22\LaravelReactions\Reactions\Love::class,
'dislike' => \Hkp22\LaravelReactions\Reactions\Dislike::class,
'fire' => \App\Reactions\Fire::class, // your own
],
Subclass the package's base Reaction for Fire and register it. Once registered, reactTo($post, 'fire') just works and reactionSummary() includes it automatically.
Switching reaction types. A user who liked a post and now wants to love it: the package treats those as two different reactions. The clean pattern is unreactTo($post, 'like') then reactTo($post, 'love') — or wrap it in an application service so controllers stay thin and the operation is transactional.
API responses. Shape the payload explicitly rather than dumping the relation:
return response()->json([
'reacted' => $user->hasReactedTo($post, 'like'),
'summary' => $post->reactionSummary(),
'count' => $post->reactions()->count(),
]);
(reacter_id, reactable_id, reactable_type, reaction_type) — without it, concurrent requests can insert duplicate rows and reactionSummary() inflates. If the migration is missing it, add it in a follow-up migration rather than editing the vendor file.'liek' throws at runtime, not at compile time. If your reaction types are user-facing, promote the config keys to a PHP enum (or at least class constants) and validate incoming payloads with Rule::in(array_keys(config('reactions.reactions'))).toggleReactionTo toggles the same type only. It will happily create a second row if the user previously reacted with a different type. Don't assume it means "clear all reactions." For a "one reaction per user" UI, unreact first.Post leaves orphaned reaction rows unless you either add a foreign key with onDelete('cascade') (won't work cleanly with polymorphic types in all DBs) or clean up in a model deleting event / observer. Left unattended, the table bloats with dead references.config/reactions.php, run php artisan config:clear (and config:cache in deploy). A stale cached config will reject your new reaction type with a confusing error.reactions when you iterate. $post->reactions()->count() in a loop is N+1. Use withCount or load('reactions').RefreshDatabase. The package's own test suite relies on migrations running cleanly; mirror that. Assert both the database row (assertDatabaseHas('reactions', [...])) and the trait query (assertTrue($user->hasReactedTo($post))) — the two going out of sync is the most common regression when you refactor.metadata JSON), subclass the base Reaction class and override its hooks rather than copy-pasting the trait into your model. The package is small enough that reading src/Reactions/ takes ten minutes and pays for itself the first time you need a custom type.^ constraint that you've tested, and check the CHANGELOG diff between whatever you pulled and the previous tag before upgrading in production.How can I help you explore Laravel packages today?