PHPackages                             laravel-afterburner/voting - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. laravel-afterburner/voting

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

laravel-afterburner/voting
==========================

Team-scoped voting and ballots for Afterburner applications

1.8.7(1mo ago)017↓88.9%1MITPHPPHP ^8.2

Since May 27Pushed 1mo agoCompare

[ Source](https://github.com/laravel-afterburner/voting)[ Packagist](https://packagist.org/packages/laravel-afterburner/voting)[ RSS](/packages/laravel-afterburner-voting/feed)WikiDiscussions master Synced 1w ago

READMEChangelogDependencies (16)Versions (24)Used By (1)

Afterburner Voting Package
==========================

[](#afterburner-voting-package)

Team-scoped ballots and vote casting for Laravel Afterburner Jetstream.

Installation
------------

[](#installation)

### Local Development Setup

[](#local-development-setup)

For local development, add the package as a path repository:

```
composer config repositories.afterburner-voting path ../afterburner-voting
composer require laravel-afterburner/voting:@dev
```

### Quick Install

[](#quick-install)

```
composer require laravel-afterburner/voting
php artisan afterburner:voting:install
```

Add the `HasVoting` trait to your `App\Models\Team` model:

```
use Afterburner\Voting\Concerns\HasVoting;

class Team extends JetstreamTeam
{
    use HasVoting;
}
```

Permissions
-----------

[](#permissions)

This package uses existing Afterburner template permission slugs:

- `vote_resolutions` — cast votes on open ballots
- `create_resolutions` — create and publish ballots

The install seeder also adds package-specific permissions:

- `manage_ballots`
- `view_ballot_results`
- `manage_proxy_votes`
- `export_ballot_results`

Voter units
-----------

[](#voter-units)

Votes are keyed to a **voter unit** (morph), not a user. `BallotResponse` stores:

- `cast_by_user_id` — who submitted the vote
- `voter_unit_type` + `voter_unit_id` — what entity the vote represents

The default resolver treats each user as their own voter unit (one person, one vote).

Strata integration
------------------

[](#strata-integration)

Strata apps assign one vote per property/lot. Implement a custom resolver:

```
namespace App\Strata\Voting;

use Afterburner\Voting\Contracts\VoterEligibilityResolver;
use Afterburner\Voting\Models\Ballot;
use Afterburner\Voting\Support\VoterUnit;
use App\Models\Property;
use App\Models\User;
use Illuminate\Support\Collection;

class PropertyVoterEligibilityResolver implements VoterEligibilityResolver
{
    public function eligibleVoterUnits(User $user, Ballot $ballot): Collection
    {
        return Property::query()
            ->where('team_id', $ballot->team_id)
            ->where(function ($query) use ($user) {
                $query->where('designated_voter_id', $user->id)
                    ->orWhereHas('activeProxies', fn ($q) => $q->where('proxy_holder_user_id', $user->id));
            })
            ->get()
            ->map(fn (Property $property) => new VoterUnit(Property::class, $property->id))
            ->reject(fn (VoterUnit $unit) => $this->alreadyVoted($ballot, $unit));
    }

    public function totalEligibleVoterUnits(Ballot $ballot): int
    {
        return Property::query()->where('team_id', $ballot->team_id)->count();
    }

    public function canCastVote(User $user, Ballot $ballot, string $voterUnitType, int $voterUnitId): bool
    {
        return $this->eligibleVoterUnits($user, $ballot)->contains(
            fn (VoterUnit $unit) => $unit->matches($voterUnitType, $voterUnitId)
        );
    }

    public function voterUnitLabel(string $voterUnitType, int $voterUnitId): string
    {
        $property = Property::query()->find($voterUnitId);

        return $property ? 'Lot '.$property->lot_number : 'Property #'.$voterUnitId;
    }

    protected function alreadyVoted(Ballot $ballot, VoterUnit $unit): bool
    {
        return $ballot->responses()
            ->where('voter_unit_type', $unit->type)
            ->where('voter_unit_id', $unit->id)
            ->exists();
    }
}
```

Register in `.env`:

```
AFTERBURNER_VOTING_ELIGIBILITY_RESOLVER=App\Strata\Voting\PropertyVoterEligibilityResolver

```

### Critical invariant

[](#critical-invariant)

`ballot_responses` has a unique constraint on `(ballot_id, voter_unit_type, voter_unit_id)`. Once a lot has voted on a ballot, changing the designated voter cannot allow a second vote for that lot.

### Weighted votes (strata entitlement)

[](#weighted-votes-strata-entitlement)

Implement `Afterburner\Voting\Contracts\ProvidesWeightedVotes` on your resolver and return unit entitlement per lot:

```
use Afterburner\Voting\Contracts\ProvidesWeightedVotes;
use Afterburner\Voting\Contracts\VoterEligibilityResolver;

class PropertyVoterEligibilityResolver implements ProvidesWeightedVotes, VoterEligibilityResolver
{
    public function voterUnitWeight(Ballot $ballot, string $voterUnitType, int $voterUnitId): float
    {
        $property = Property::query()->find($voterUnitId);

        return (float) ($property?->vote_weight ?? 1);
    }
}
```

Tally and CSV/PDF exports use weighted counts when the bound resolver implements this contract, or when the team default vote weight per lot is set in voting settings.

### Multi-lot owner voting

[](#multi-lot-owner-voting)

When a user is eligible to vote for two or more owned lots (not proxies) on the same ballot, the ballot page defaults to a single form that records the same choice for all listed lots. A **Vote separately for each lot** option switches to per-lot forms. Proxy lots always use individual forms.

Phase 3 features
----------------

[](#phase-3-features)

FeatureConfig / usageVote revocation (withdraw vote, no re-cast)`AFTERBURNER_VOTING_ALLOW_VOTE_REVOCATION=true` — tombstone in `ballot_vote_revocations`Scheduled open/close`AFTERBURNER_VOTING_SCHEDULE_TRANSITIONS=true` — queued jobs on publish + `afterburner:voting:process-scheduled` every minutePDF results exportInstall `barryvdh/laravel-dompdf`, export via `?format=pdf` on results export routeWeighted tallyResolver implements `ProvidesWeightedVotes`Attendance tracking is intentionally deferred to a future meetings package.

Team voting settings
--------------------

[](#team-voting-settings)

Team admins can configure defaults in **System Settings → Voting** (`/teams/{team}/system-settings`):

SettingPurposeDefault quorum (%)Applied to new ballots; optionalDefault vote weight per lotSet to `1` when every lot counts equally; leave empty to set weight per lot in the host property registerDefault vote visibilityConfidential, visible after close, or visible in realtimeAllow proxy votesTeam-level toggle (global kill switch in config still applies)Lock designation during open ballotsStored preference for host apps; not enforced by this packageNew ballots inherit team defaults via `TeamVotingSettings`. Individual ballots can override quorum and visibility on the create form.

`HasVoting::votingSettings()` exposes the `TeamVotingSetting` record for the team.

Custom electorate
-----------------

[](#custom-electorate)

For ballots with `electorate = custom`, register a class implementing `CustomElectorateResolver`:

```
AFTERBURNER_VOTING_CUSTOM_ELECTORATE_RESOLVER=App\Voting\MyCustomElectorateResolver
```

The class is validated on boot and required when publishing custom-electorate ballots.

Voter notifications
-------------------

[](#voter-notifications)

The package fires `BallotPublished` but does not send email. A stub listener `SendBallotPublishedVoterNotifications` is registered by default. Subscribe to `BallotPublished` in your host app (or replace the listener) to notify eligible voters.

Routes
------

[](#routes)

- `/teams/{team}/ballots` — ballot index
- `/teams/{team}/ballots/create` — create ballot
- `/teams/{team}/ballots/{ballot}` — ballot detail and voting
- `/teams/{team}/ballots/{ballot}/results` — results after close
- `/teams/{team}/ballots/{ballot}/results/export` — CSV (default) or PDF (`?format=pdf`)
- `/teams/{team}/voting/proxies` — proxy vote management (when `AFTERBURNER_VOTING_PROXY_GRANT_RESOLVER` is configured)

Team voting defaults are configured under **System Settings → Voting** (`/teams/{team}/system-settings`).

Testing
-------

[](#testing)

```
composer test
```

Or:

```
./vendor/bin/phpunit
```

Document attachments
--------------------

[](#document-attachments)

When [`laravel-afterburner/documents`](https://github.com/laravel-afterburner/documents) is installed, ballots can link to completed team documents so voters can review supporting material.

1. Run documents migrations (includes `document_links`):

```
php artisan migrate
```

2. Ensure both packages are installed in the host app (Strata already uses path repos for both).

On the ballot **show** and **edit** pages, a **Supporting documents** section lists attached files. Preview (eye icon) opens PDFs, images, and plain text in the browser via `teams.documents.preview`; download remains available when permitted.

Linking uses the documents package `document_links` pivot (`LinkDocument` / `UnlinkDocument` actions). Only `upload_status = completed` documents can be attached. Documents must belong to the same team as the ballot.

Disable integration with `AFTERBURNER_VOTING_DOCUMENTS_ENABLED=false`.

UI conventions
--------------

[](#ui-conventions)

Package views use the host app's Blade button components (same as `afterburner-documents`):

- `` — primary actions (Create Ballot, Publish, Submit Vote)
- `` — secondary actions (Save Draft, Close, View Results)
- `` — destructive actions
- Icon-only inline row actions — remove/edit/delete beside list rows (SVG + `title`, no visible text; see documents `index.blade.php`)

Do not use raw `bg-indigo-*` classes for buttons. Do not use text labels like "Remove" on compact row actions. Republish views after UI updates:

```
php artisan vendor:publish --tag=afterburner-voting-assets --force
php artisan view:clear
```

License
-------

[](#license)

MIT License — see [LICENSE](LICENSE) for details.

###  Health Score

42

—

FairBetter than 88% of packages

Maintenance90

Actively maintained with recent releases

Popularity6

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity56

Maturing project, gaining track record

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

23

Last Release

48d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/c89a4bde39061db2498ed98e084ccab43ec033d496994ade8a8beed1b0bd0450?d=identicon)[FoxRocks76](/maintainers/FoxRocks76)

###  Code Quality

TestsPHPUnit

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/laravel-afterburner-voting/health.svg)

```
[![Health](https://phpackages.com/badges/laravel-afterburner-voting/health.svg)](https://phpackages.com/packages/laravel-afterburner-voting)
```

###  Alternatives

[team-nifty-gmbh/tall-datatables

Server-side rendered datatables for Laravel and Livewire

1320.9k4](/packages/team-nifty-gmbh-tall-datatables)[tomshaw/electricgrid

A feature-rich Livewire package designed for projects that require dynamic, interactive data tables.

119.4k](/packages/tomshaw-electricgrid)[noerd/noerd

101.4k10](/packages/noerd-noerd)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
