msalsas/voting-bundle
A Symfony bundle that adds a flexible voting system to your Doctrine entities. Let users upvote or downvote items anonymously or as registered voters, store votes via Doctrine, and display results with built-in Twig helpers.
Reality check first: msalsas/voting-bundle is a Symfony bundle, not a Laravel package. It ships Doctrine entities, a manager service, and Twig helpers tied to the Symfony service container and Doctrine ORM. You cannot composer require it into a Laravel app and expect it to boot. The realistic options, in order of effort:
cybercog/laravel-love, rtconner/laravel-likeable, overtrue/laravel-like) if you just need likes/ratings.If you are on Symfony, the first-30-minutes path is:
composer require msalsas/voting-bundle
Enable it in config/bundles.php, then look at these four things in the repo, in this order:
README.md — the canonical wiring example (entity extension, service injection, event subscriber).src/Entity/ — the abstract Vote class you are expected to extend.src/VotableItemInterface — the contract your "thing being voted on" must implement.VoteManager (or equivalent manager service) — this is the API you will actually call day to day.First use case: a 1–5 star rating on an Article. You extend the bundle's Vote entity to add a ManyToOne to your User, implement VotableItemInterface on Article (getId(), average, vote count), map with Doctrine, generate a migration, and expose a single POST /articles/{id}/vote endpoint that calls the manager.
If you are on Laravel, the same first use case looks like: copy the Vote table shape (id, voter_type/voter_id polymorphic, votable_type/votable_id, value, timestamps), add a Votable trait with votes() / averageVote() / voteCount(), and skip the bundle entirely.
Extend, then constrain. The bundle gives you a base Vote entity but leaves the relations to you. The pattern is always: extend the abstract class, add your voter relation (usually User) and your votableItem relation, then add a database unique index on (voter_id, votable_item_id). Do not rely on the bundle to stop double votes — it won't.
Wrap the manager, don't sprinkle it. Inject the manager service into a thin application service (ArticleRatingService) rather than calling it from controllers. This is the single biggest lever for keeping the bundle swappable:
class ArticleRatingService
{
public function __construct(private VoteManager $votes) {}
public function rate(User $user, Article $article, int $value): void
{
if ($value < 1 || $value > 5) {
throw new \InvalidArgumentException('Rating must be 1–5');
}
$this->votes->vote($user, $article, $value); // or upsert, depending on semantics
}
}
Handle "re-vote" as an upsert, not an insert. Most rating UIs let a user change their mind. The bundle's default vote() typically inserts; guard it by querying the existing vote first and updating the value, or catch the unique-constraint violation and update. Decide explicitly whether re-voting is replace, toggle (upvote → neutral), or forbidden.
Two distinct shapes — pick one early. Upvote/downvote (value ∈ {-1, +1}, sum is the score) and star rating (value ∈ 1..5, mean is the average) are different products. The bundle supports both mechanically, but your aggregate queries, caching, and UI are different. Don't mix them on the same model.
Denormalize the aggregate for reads. averageVote() and numberOfVotes() are almost always a SELECT AVG/COUNT under the hood. On an index page of 50 articles that is 100 queries. Add votes_sum / votes_count columns to the votable model, update them in the same transaction as the vote (or via the bundle's event subscriber), and read from those.
Use the event hooks for side effects. The bundle fires a vote event. Subscribe to it for things that should not live in the vote path: granting badges, invalidating a cache key, pushing to a queue, writing an audit log. Keep the listener synchronous only if it's cheap; otherwise dispatch a job.
class VoteListener
{
public function onVote(VoteEvent $event): void
{
Cache::forget("article:{$event->getVotableItem()->getId()}:rating");
}
}
Rendering: the bundle's Twig extension (star rendering / percentage) is a convenience, not a feature you should depend on. In Laravel, a Blade partial that takes (average, count) and renders five icons is ten lines and gives you full control over half-stars and accessibility.
Integration tip for Laravel teams: define your own Votable interface and RatingService contract before writing any implementation. That way, if you later decide to adopt a native package or migrate off the copied pattern, only the concrete class changes.
CHECK or a tinyint with an application-level guard.UNIQUE(voter, votable_item), a double-click produces two votes and a skewed average. Add it in the migration, then handle the collision in the service.Article is deleted without ON DELETE CASCADE on the votes FK, you accumulate orphans and your AVG is computed over ghosts. Set the FK behaviour explicitly and add a cleanup test.LEFT JOIN/withCount, or keep denormalized counters. Profile the index page before shipping.Gate/voter — can this user rate this item?), throttling (vote spam), and the response shape.src/ directly rather than trusting the README to be current. Pin the version and vendor-fork if you must depend on it.AVG and COUNT. Most rating bugs are off-by-one in the aggregate or a stale denormalized counter, not the vote itself.created_at, ip), and subclassing the manager if you need custom aggregation. Prefer subclassing over forking.RatingService interface from day one. When the package goes quiet (or when you move to Laravel), you swap the implementation and nothing else in the codebase notices.How can I help you explore Laravel packages today?