Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Love Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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/.

Implementation Patterns

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.

Gotchas and Tips

  • The joins are inner joins. 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 Object pattern can hide missing data. Models created before you installed the package have a 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.
  • Nullable columns are the v9+ default. 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.
  • Counters are denormalised. Any write to 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.
  • Table names come from config (fixed in 10.0.1). If you renamed tables via 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.
  • Joining the same counter table twice needs aliases. Two 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.
  • Watch the exception types. 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.
  • v10 made package models unguarded. If you subclass or extend Reaction/ReactionType, don't assume $fillable protects anything — the service layer is your guard rail.
  • Upgrade path bites. v8→v9 refactored the scopes into 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.
  • Orphan risk on deletion. Reactions reference reactants/reacters polymorphically; if you hard-delete a 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core