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

Efficient Vote Bundle Laravel Package

kinulab/efficient-vote-bundle

A lightweight Symfony bundle for implementing efficient voting in PHP applications. Add vote handling with minimal overhead, keep logic clean, and integrate quickly into existing projects. Suitable for polls, ratings, and other simple voting features.

View on GitHub
Deep Wiki
Context7

Getting Started

Know what you're actually installing. kinulab/efficient-vote-bundle is a Symfony bundle, discovered and booted by Symfony's Kernel/Bundle mechanism. There is no ServiceProvider, no config/vote.php façade for Laravel, and no symfony/* integration that Laravel's Gate/Policy stack will consume. composer require will fetch it, then sit idle — it will never register a vote manager in your container. Treat it as a reference implementation of a pattern, not a dependency.

What the pattern actually is. Strip the Symfony packaging and the bundle ships four primitives worth copying:

  • A vote manager for casting and retracting a vote (voter → target).
  • Duplicate-vote protection per (voter, target) pair — one vote per voter-target.
  • Aggregated tally counts kept cheap to read as voter count grows — i.e., denormalized counters, not COUNT(*) over a votes table on every page.
  • Target wiring — the bundle config declares which resources are votable, so the manager knows the target types.

Where to look first. Read the bundle's DependencyInjection/Configuration.php and the vote manager service (usually a VoteManager or VoteService class). The tally strategy and the uniqueness enforcement are the only two things that determine whether the idea is worth porting. Everything else is Symfony ceremony you'll delete.

First use case for a Laravel dev. "Likes" / upvotes on Post:

  1. Migration post_votes with columns user_id, voteable_type, voteable_id, value (or a boolean for plain likes), and a unique index on (user_id, voteable_type, voteable_id). That index is the duplicate-vote protection — do not rely on an application-level exists() check.
  2. Add a votes_count (or score) column to the target table for the tally.
  3. A VoteService with cast(User $user, Model $target) and retract(...), each wrapped in a transaction that writes the vote row and increment/decrements the tally.
  4. Read the tally directly off the target — never aggregate at read time.

That is roughly 60–100 lines and replaces the entire bundle.

Implementation Patterns

Denormalized tally on the target row. Add votes_count / score to the votable table, not a separate tallies table, unless a target can be voted on along many axes (upvote vs. downvote vs. star) in which case a small tallies side table keyed by (target, dimension) is cleaner. The whole point is that listing, sorting, and ranking read one indexed integer column instead of joining votes.

Cast/retract as a single transactional service. The bundle's vote manager exists so that the write-side of (vote row, tally) never drifts. Mirror that:

public function cast(User $user, Model $target): void
{
    DB::transaction(function () use ($user, $target) {
        $inserted = DB::table('votes')->insertOrIgnore([
            'user_id' => $user->id,
            'voteable_type' => $target->getMorphClass(),
            'voteable_id' => $target->getKey(),
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        if ($inserted > 0) {
            $target->newQuery()->whereKey($target)->increment('votes_count');
        }
    });
}

insertOrIgnore + unique index makes duplicate-vote protection a database guarantee, not a check-then-insert race. Retract is the mirror with decrement gated on a successful delete.

Toggles vs. separate operations. Decide up front whether "vote" is idempotent (POST /votes = ensure exists) or a toggle (POST /votes/toggle). If you expose both cast and retract, keep them distinct methods; conflating them is how you get "user clicked twice and their vote vanished."

Wire votable targets like the bundle's config does. Symfony declares target types in bundle config. In Laravel, express the same thing as a morph map (Relation::enforceMorphMap([...])) so voteable_type is a stable, constrained vocabulary — the bundle's "configuration for wiring vote targets" is solving exactly this problem, and it's easy to skip and regret.

Read tallies with eager-loading and no aggregates. For a listing: Post::withCount([])->orderByDesc('votes_count') is fine, but if the column is already denormalized, just orderBy('votes_count') and select it. Avoid withCount('votes') on a page of 50 rows — that's the pre-optimization state the package is trying to eliminate.

Authorization is Laravel's job, not the vote manager's. Whether a user may vote on a given target is a policy concern (PostPolicy::vote). The bundle in Symfony would route this through its security voter pipeline — in Laravel you write it once in a policy and call $this->authorize('vote', $post) in the controller or @can('vote', $post) in Blade. Do not put permission logic in the vote service.

Idempotent tallies for bulk/import flows. Any backfill (importing votes, adding a new votable type) should write votes and recompute tallies with a single UPDATE ... SET votes_count = (SELECT COUNT(*) ...), not loop increments. That's also your reconciliation job.

Queue-friendly write path. If vote casting is high-volume, push the tally update to a queue job after the vote row is committed (DB::afterCommit). The row insert is the source of truth; the counter is a cache.

Gotchas and Tips

It will not boot in Laravel. The bundle suffix and the symfony/* runtime dependencies mean Composer will install it but nothing wires up. Laravel does not process Symfony Extension classes. Block it at intake; port the pattern.

Counter races beat you if you check-then-write. The unique index is the only correct duplicate-vote protection under concurrency. if (!Vote::where(...)->exists()) { ... } is a lie under load. Use insertOrIgnore/firstOrCreate against the constraint and branch on the affected-row count.

Retract must respect the same constraint. A decrement that fires without a matching successful delete will silently drift the tally negative or over-count. Gate the decrement on DELETE ... WHERE id = ? returning > 0.

Soft deletes break re-voting. If votes are soft-deletable, the unique index still sees the deleted row — the user can never vote again. Either hard-delete votes, or make the unique index include a deleted_at IS NULL-style partial index (Postgres/SQLite) or a deleted_at column in the key (MySQL workaround). This is the single most common bug in hand-rolled voting.

Tally drift is real; ship the reconciliation job from day one. votes_count will eventually disagree with votes after a bug, a failed transaction, or a manual DB edit. A scheduled job that recomputes votes_count from the votes table is your safety net; the bundle's "aggregated tallies" are only trustworthy if you can prove they're derived.

Tenant scoping must be in the unique key. Multi-tenant apps have collided user_id/voteable_id before. Include tenant_id in the unique index if user and target IDs aren't globally unique, or use globally unique IDs (UUID/ULID) and stop worrying.

Don't cache tallies across requests without an invalidation story. In-memory memoization per request is fine and boring; Redis/APCu caches of vote counts reintroduce the class of problem that makes a security-critical cache dangerous, only for a non-critical feature. If in doubt, read the integer column from the DB.

Long-running workers (Horizon, Octane) invert your assumptions. A "request-scoped" cache in a worker is process-scoped. If you memoize tallies anywhere, invalidate on write, not on request boundary.

Symfony-version skew can quietly break the bundle if you did try it on a Symfony service. Laravel ships several symfony/* packages transitively; pinning a bundle that constrains symfony/security-core to a major version Laravel doesn't use is a conflict waiting to happen on composer update.

Extension points are the pattern, not the package. The two things worth stealing are (a) the unique-constraint-based duplicate guard and (b) the denormalized tally with a recompute path. Everything else — the vote manager service, the bundle config, the DI wiring — exists to serve Symfony's container and is replaced by one Laravel service class and one migration.

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.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource