Looking to hire Laravel developers? Try LaraJobs

voting maintained by laravel-afterburner

Description
Team-scoped voting and ballots for Afterburner applications
Last update
2026/05/29 17:15 (dev-master)
License
Links
Downloads
0

Comments
comments powered by Disqus

Afterburner Voting Package

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

Installation

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

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

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

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

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)

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.

Phase 3 features

Feature Config / usage
Vote 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 minute
PDF results export Install barryvdh/laravel-dompdf, export via ?format=pdf on results export route
Weighted tally Resolver implements ProvidesWeightedVotes

Attendance tracking is intentionally deferred to a future meetings package.

Team voting settings

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

Setting Purpose
Default quorum (%) Applied to new ballots; optional
Default vote visibility Secret, visible after close, or visible in realtime
Allow proxy votes Team-level toggle (global kill switch in config still applies)
Lock designation during open ballots Stored preference for host apps; not enforced by this package

New 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

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

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

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

composer test

Or:

./vendor/bin/phpunit

Document attachments

When 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
  1. 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

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

  • <x-button> — primary actions (Create Ballot, Publish, Submit Vote)
  • <x-secondary-button> — secondary actions (Save Draft, Close, View Results)
  • <x-danger-button> — 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

License

MIT License — see LICENSE for details.