cybercog/laravel-love
Laravel Love adds an emotional layer to your Laravel app: make any model reactable in minutes so users can like, vote, or react to content, similar to GitHub reactions, YouTube likes, or Reddit votes. A drop-in Composer package covering all use cases, built for enterprise-ready applications.
Install and wire up the schema (5 minutes)
composer require cybercog/laravel-love
php artisan migrate # creates love_reactions, love_reaction_types,
# love_reaction_counters, love_reaction_totals
Optionally publish the config first if you need a non-default DB connection or custom table names:
php artisan vendor:publish --provider="Cog\Laravel\Love\LoveServiceProvider"
Register the two roles on your models. A Reacter acts (usually User), a Reactant is acted upon (a Post, Comment, Video…). The Artisan commands generate the migration that adds the linking column and index — run them, then migrate:
php artisan love:setup-reacterable "App\Models\User"
php artisan love:setup-reactable "App\Models\Post"
php artisan migrate
Then attach the traits + contracts:
use Cog\Contracts\Love\Reactable\Models\Reactable as ReactableContract;
use Cog\Laravel\Love\Reactable\Models\Traits\Reactable;
class Post extends Model implements ReactableContract
{
use Reactable;
}
use Cog\Contracts\Love\Reacterable\Models\Reacterable as ReacterableContract;
use Cog\Laravel\Love\Reacterable\Models\Traits\Reacterable;
class User extends Authenticatable implements ReacterableContract
{
use Reacterable;
}
Seed your emotion vocabulary — reaction types are rows, not enum cases, and one of them must be the default:
php artisan love:reaction-type-add --name=Like --mass=1 --default
php artisan love:reaction-type-add --name=Dislike --mass=-1
First use case — a like button. The fluent entry points are viaLoveReacter() and viaLoveReactant():
// POST /posts/{post}/like
$post->viaLoveReacter === null; // n/a — use the acting model:
auth()->user()->viaLoveReacter()->reactTo($post); // default type + rate 1.0
$post->viaLoveReactant()->getReactionCounterOfType('Like'); // count() / weight()
Where to look first: the Laravel Love Guide for the full API, then vendor/cybercog/laravel-love/contracts/ — that folder is the public surface (Reactable, Reactant, Reacter, ReactionType, ReactionCounter, ReactionTotal). Read the contracts before reading src/.
Idempotent toggle endpoint. Reactions are naturally unique per (reacter, reactant, type), so a toggle is safe to retry:
public function toggle(Request $request, Post $post)
{
$reacter = $request->user()->viaLoveReacter();
if ($reacter->hasReactedTo($post, 'Like')) {
$reacter->unreactTo($post, 'Like');
} else {
$reacter->reactTo($post, 'Like');
}
$counter = $post->viaLoveReactant()->getReactionCounterOfType('Like');
return PostResource::make($post)->additional([
'likes' => ['count' => $counter->getCount(), 'weight' => $counter->getWeight()],
'reacted' => $reacter->hasReactedTo($post, 'Like'),
]);
}
Return the fresh counter from the server rather than incrementing client-side — the aggregate rows are the source of truth.
Weighted reactions (Medium claps / ratings). mass lives on the type and rate per reaction (0.01–99.99). count = number of reactions, weight = Σ(rate × mass). Call reactTo() again with the same type but a different rate to update the existing reaction:
$user->viaLoveReacter()->reactTo($post, 'Clap', 12.5);
Order by weight in lists where rates differ; order by count for plain likes.
Feed / index pages without N+1. Don't call getReactionTotal() in a loop over 50 posts. Join the aggregate tables in the query:
Post::query()
->joinReactionCounterOfType('Like')
->joinReactionTotal()
->orderByDesc('reaction_totals.weight')
->select('posts.*')
->addSelect([
'reaction_counters.count as likes_count',
'reaction_totals.count as reactions_count',
])
->paginate();
joinReactionCounterOfType() can be called multiple times (e.g. Like + Dislike), but each call needs distinct column aliases in addSelect.
Filtering by who reacted. The Reactable scopes cover "my likes" and the inverse:
Post::query()->whereReactedBy($user, 'Like')->latest()->get();
Post::query()->whereNotReactedBy($user, 'Like')->inRandomOrder()->first(); // "recommend" query
On Reacterable models (v9+) you also get whereReactedTo / whereNotReactedTo / whereReactedToBetween — useful for "posts this user liked, paginated".
React to events, not rows. ReactionHasBeenAdded / ReactionHasBeenRemoved are dispatched by the package; subscribe to them for notifications, activity feeds, websocket broadcasts and analytics instead of overriding the service:
Event::listen(ReactionHasBeenAdded::class, fn ($e) => NotifyAuthorOfReaction::dispatch($e));
Tests. Because migrations are auto-loaded, RefreshDatabase gives you the tables. Seed reaction types in setUp() (or a seeder shared with prod) so reactTo() without an explicit type resolves the default:
protected function setUp(): void
{
parent::setUp();
$this->artisan('love:reaction-type-add', ['--name' => 'Like', '--mass' => 1, '--default' => true])->run();
}
Assert against getReactionCounterOfType(...)->getCount() — never against raw rows you inserted by hand.
Deployment ops. After a backfill, a bulk import, or a bad deploy that wrote to love_reactions directly, rebuild the aggregates:
php artisan love:recount # interactive, progress bar
php artisan love:recount --queue-connection=redis # push onto a queue for big tables
Put it in a deploy step or a scheduled job when you reconcile data.
joinReactionCounterOfType() / joinReactionTotal() drop models that have no counter/total row — i.e. posts nobody has touched disappear from your feed. Always run ->toSql() once, and fall back to an explicit leftJoin on love_reaction_totals (or withCount) for "all posts, sorted by popularity".NULL love_reactant_id and get a NullReactant/NullReactionTotal — getReactionTotal()->getCount() returns 0 rather than throwing. That looks identical to "no reactions yet". Backfill reactant rows for existing records in a one-off queued job, then verify with love:recount.love:setup-reactable and love:setup-reacterable generate love_reactant_id / love_reacter_id as nullable; the old --nullable flag is now --not-nullable. If you want the DB to reject unreactioned rows, opt in explicitly — but then seed/backfill before adding the constraint.hasReactedTo() compares the rate when you pass one. For clap-style reactions hasReactedTo($post, 'Clap', 5) is false for a user who clapped 3 times — this is the #1 source of "the button state is wrong" bugs. Omit the rate for simple like/dislike toggles.love_reactions that bypasses reactTo()/unreactTo() (raw inserts, imports, queued mass updates) desynchronises love_reaction_counters and love_reaction_totals. There is no automatic repair — schedule love:recount.config/love.php, your hand-written orderBy/addSelect column references must use the configured names, not the package defaults. Also set connection there if reactions live in a separate DB — cross-connection joins will fail.joinReactionCounterOfType() calls (Like + Dislike) produce two identically named column sets; alias both in addSelect or you'll silently read the wrong one.mass is signed and cached-by-lookup. Dislike = -1 so total->getWeight() can go negative — clamp for display, but never clamp the stored value. Reaction types are resolved by name on each call; cache the type list (or the id map) in a service provider if you call reactTo() in hot loops.RateOutOfRange and RateInvalid come from Cog\Contracts\Love\Reaction\Exceptions\; validate rate and reaction_type at the FormRequest layer so the controller doesn't have to translate domain exceptions into 422s.Reaction/ReactionType, don't assume $fillable protects anything — the service layer is your guard rail.ReactableEloquentBuilderTrait (custom Builder subclasses are the usual breakage), and v10 removed ReactionCounter/ReactionTotal observers and legacy factories. Follow UPGRADING.md before bumping the major version; run love:recount right after, since aggregate semantics changed between v7 and v8.Post, verify (with a test) that its reaction rows and counters are gone — otherwise your recount numbers and "top posts" lists drift. Soft deletes are fine, but remember soft-deleted reactants still count in global joins unless you filter them.How can I help you explore Laravel packages today?