aftandilmmd / laravel-poller
A powerful, flexible poll and voting package for Laravel. Supports multiple poll types, anonymous voting, scheduled polls, and Livewire components.
Requires
- php: ^8.2
- illuminate/database: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.0|^4.0
Suggests
- livewire/livewire: Required for Livewire poll components (^3.0|^4.0)
README
English | Türkçe | Azərbaycanca
Laravel Poller
A powerful, flexible poll and voting package for Laravel. Supports 5 poll types, anonymous voting, scheduled polls, vote changing, and both Livewire components and a RESTful API.
Requirements
- PHP 8.2+
- Laravel 11, 12, or 13 (Laravel 13 requires PHP 8.3+)
Installation
composer require aftandilmmd/laravel-poller
The service provider and facade are auto-discovered.
Publish the config file:
php artisan vendor:publish --tag=poller-config
Publish migrations (optional - migrations run automatically):
php artisan vendor:publish --tag=poller-migrations
Publish views (optional - for customization):
php artisan vendor:publish --tag=poller-views
Publish translations (optional - for customization):
php artisan vendor:publish --tag=poller-translations
Run migrations:
php artisan migrate
Configuration
Full config options in config/poller.php:
| Key | Description | Default |
|---|---|---|
user_model |
Your User model class | App\Models\User |
tables.polls |
Polls table name | poller_polls |
tables.options |
Options table name | poller_poll_options |
tables.votes |
Votes table name | poller_poll_votes |
features.anonymous_voting |
Enable anonymous voting | true |
features.vote_changing |
Enable vote changing | true |
features.vote_retraction |
Enable vote retraction | true |
features.vote_comments |
Enable vote comments | true |
features.auto_close |
Auto-close expired polls | true |
features.auto_open |
Auto-open scheduled polls | true |
features.custom_options |
Allow users to add custom options | true |
features.poll_scheduling |
Enable poll scheduling (starts_at/ends_at) | true |
features.soft_deletes |
Enable soft deletes on polls | true |
rating.min |
Rating scale minimum | 1 |
rating.max |
Rating scale maximum | 5 |
pagination.polls |
Polls per page | 20 |
pagination.votes |
Votes per page | 50 |
api.enabled |
Enable REST API routes | false |
api.rate_limit |
API requests per minute | 60 |
Setup
Add poll support to any model (Pollable)
use Aftandilmmd\Poller\Traits\HasPolls; class Meeting extends Model { use HasPolls; }
Add voting capabilities to User model
use Aftandilmmd\Poller\Traits\InteractsWithPolls; class User extends Authenticatable { use InteractsWithPolls; // Override for custom authorization: public function canCreatePoll(): bool { return $this->is_admin; } public function canVote(Poll $poll): bool { return $poll->isVotingOpen() && $this->hasActiveSubscription(); } public function canAddCustomOption(Poll $poll): bool { return $poll->allowsCustomOptions() && $this->is_premium; } public function canManagePoll(Poll $poll): bool { return $poll->created_by === $this->id || $this->is_admin; } }
Poll Types
| Type | Description |
|---|---|
YesNo |
Simple yes/no voting |
SingleChoice |
Select one option |
MultipleChoice |
Select multiple options (with min/max constraints) |
Rating |
Rate options on a configurable scale (default 1-5) |
Ranked |
Rank options by preference |
Usage
Via Facade
use Aftandilmmd\Poller\Facades\Poller; // Create a poll $poll = Poller::create([ 'title' => 'Best framework?', 'type' => 'single_choice', 'is_anonymous' => false, 'show_results_before_close' => true, 'allow_vote_change' => true, ], $user); // Add options Poller::addOption($poll, ['title' => 'Laravel']); Poller::addOption($poll, ['title' => 'Django']); Poller::addOption($poll, ['title' => 'Rails']); // Activate the poll Poller::activate($poll); // Cast a vote Poller::castVote($poll, $user, $optionId); // Cast vote with comment Poller::castVote($poll, $user, $optionId, ['comment' => 'Great choice!']); // Change a vote Poller::changeVote($poll, $user, $newOptionId); // Retract a vote Poller::retractVote($poll, $user); // Get results $results = Poller::getResults($poll); // [['option_id' => 1, 'title' => 'Laravel', 'votes_count' => 15, 'percentage' => 75.0], ...] $detailed = Poller::getDetailedResults($poll); // ['poll' => ..., 'total_votes' => 20, 'unique_voters' => 18, 'options' => [...], 'leading_option' => ...] // Lifecycle Poller::close($poll); Poller::cancel($poll); // Reorder options Poller::reorderOptions($poll, [$optionId3, $optionId1, $optionId2]); // Duplicate a poll (copies all options) $newPoll = Poller::duplicate($poll, ['title' => 'Copy of poll']);
Custom Options
Allow voters to add their own options to a poll. Control the maximum number and who can add them.
// Create a poll with custom options enabled (max 5) $poll = Poller::create([ 'title' => 'Best framework?', 'type' => 'single_choice', 'allow_custom_options' => true, 'max_custom_options' => 5, // null = unlimited ], $user); // Add a custom option (via Facade) Poller::addCustomOption($poll, $user, ['title' => 'My suggestion']); // Add a custom option (via User model) $user->addCustomOption($poll, ['title' => 'My suggestion']); // Check helpers $poll->allowsCustomOptions(); // true $poll->getCustomOptionCount(); // 1 $poll->hasReachedCustomOptionLimit(); // false $option->isCustom(); // true $option->creator; // User who added it
Override canAddCustomOption() in your User model to control authorization:
public function canAddCustomOption(Poll $poll): bool { return $poll->allowsCustomOptions() && $this->is_premium; }
The Livewire PollVote (poller-poll-vote) widget automatically shows a "Add your own option" input when custom options are enabled and the user is authorized.
Via Poll Model
// Lifecycle $poll->activate(); $poll->close(); $poll->cancel(); // Reorder options $poll->reorderOptions([$optionId3, $optionId1, $optionId2]); // Duplicate $newPoll = $poll->duplicate(['title' => 'Copy']);
Via Pollable Model
// Create a poll attached to a meeting $poll = $meeting->createPoll([ 'title' => 'Meeting agenda vote', 'type' => 'multiple_choice', 'min_selections' => 1, 'max_selections' => 3, ], $user); // Get polls $meeting->polls; $meeting->activePolls; $meeting->closedPolls; $meeting->hasPollsInProgress();
Via User Model (InteractsWithPolls trait)
$user->vote($poll, $optionId); $user->changeVote($poll, $newOptionId); $user->retractVote($poll); $user->hasVotedOn($poll); // true/false $user->getVotesFor($poll); // Collection of PollVote $user->createdPolls; // HasMany $user->pollVotes; // HasMany
Livewire Components
The package includes 5 ready-to-use Livewire components with full Tailwind CSS UI (dark mode supported).
Note: Livewire components are optional. Projects without Livewire can use the Facade API or REST API directly.
Poll Manager (Full CRUD)
<livewire:poller-poll-manager /> {{-- Scoped to a specific model --}} <livewire:poller-poll-manager :pollable="$meeting" />
Features: Search, filter by status/type, create, edit, delete, activate, close, duplicate polls.
Poll Form (Create/Edit)
<livewire:poller-poll-form /> <livewire:poller-poll-form :poll-id="$poll->id" />
Poll Display (Full View)
<livewire:poller-poll-display :poll="$poll" />
Shows poll info, stats, voting UI, results, and vote history tabs.
Poll Results (Analytics)
<livewire:poller-poll-results :poll="$poll" />
Displays bar chart results with percentages and leading option.
Vote Widget (Compact)
<livewire:poller-poll-vote :poll="$poll" />
Embeddable voting widget. Handles all 5 poll types with the appropriate UI (radio, checkbox, rating scale, ranking).
Customizing Views
php artisan vendor:publish --tag=poller-views
Views will be published to resources/views/vendor/poller/.
REST API
Enable the API in your config:
// config/poller.php 'api' => [ 'enabled' => true, 'prefix' => 'api/polls', 'middleware' => ['api', 'auth:sanctum'], 'rate_limit' => 60, // requests per minute (null to disable) ],
All mutation endpoints (update, delete, lifecycle actions, option management) enforce ownership checks. If your User model uses the InteractsWithPolls trait, the canManagePoll() method is used for authorization.
API responses use Eloquent API Resources for consistent JSON formatting.
Endpoints
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/polls |
List polls (with filters) |
POST |
/api/polls |
Create poll |
GET |
/api/polls/{poll} |
Show poll |
PUT |
/api/polls/{poll} |
Update poll |
DELETE |
/api/polls/{poll} |
Delete poll |
POST |
/api/polls/{poll}/activate |
Activate |
POST |
/api/polls/{poll}/close |
Close |
POST |
/api/polls/{poll}/cancel |
Cancel |
POST |
/api/polls/{poll}/duplicate |
Duplicate |
POST |
/api/polls/{poll}/options |
Add option |
PUT |
/api/polls/{poll}/options/{option} |
Update option |
DELETE |
/api/polls/{poll}/options/{option} |
Remove option |
POST |
/api/polls/{poll}/options/reorder |
Reorder options |
POST |
/api/polls/{poll}/vote |
Cast vote |
PUT |
/api/polls/{poll}/vote |
Change vote |
DELETE |
/api/polls/{poll}/vote |
Retract vote |
GET |
/api/polls/{poll}/results |
Get results |
GET |
/api/polls/{poll}/votes |
List votes |
Example: Cast a Vote
curl -X POST /api/polls/1/vote \ -H "Authorization: Bearer $TOKEN" \ -d '{"options": [3], "comment": "My pick"}'
Commands
Scheduled Commands
Add to your scheduler for automatic poll lifecycle management:
// routes/console.php or bootstrap/app.php Schedule::command('poller:auto-open')->everyMinute(); Schedule::command('poller:auto-close')->everyMinute();
poller:auto-open-- Activates draft polls whosestarts_athas passedpoller:auto-close-- Closes active polls whoseends_athas passed
Maintenance Commands
# Recalculate all option vote counts from actual vote records
php artisan poller:reconcile-counts
Events
All events are configurable via config/poller.php. Set to null to disable.
| Event | Payload |
|---|---|
PollCreated |
Poll, creator |
PollActivated |
Poll |
PollClosed |
Poll |
PollCancelled |
Poll |
VoteCast |
Poll, voter, votes |
VoteChanged |
Poll, voter, oldVotes, newVotes |
VoteRetracted |
Poll, voter |
// Listen to events Event::listen(VoteCast::class, function ($event) { // $event->poll, $event->voter, $event->votes });
Advanced Features
All advanced features below are opt-in via config/poller.php. Defaults keep behavior unchanged.
Result Caching
Cache poll results to avoid recomputing on every request. Cache is invalidated automatically when votes are cast, changed, or retracted.
// config/poller.php 'cache' => [ 'enabled' => true, 'store' => null, // null = default cache store 'ttl' => 60, // seconds 'prefix' => 'poller', ],
$poll->getResultsAsPercentages(); // first call hits DB, subsequent calls hit cache $poll->flushResultsCache(); // manual invalidation
Broadcasting
Make poll/vote events broadcast over Laravel Echo / WebSockets. Channel name pattern: {prefix}.{pollId}.
// config/poller.php 'broadcasting' => [ 'enabled' => true, 'channel' => 'private', // private | presence | public 'channel_prefix' => 'poller.poll', ],
// resources/js — listen on the frontend Echo.private(`poller.poll.${pollId}`) .listen('VoteCast', (e) => updateChart(e.poll));
Voter Rate Limiting
Limit how many votes a single voter can cast across all polls in a sliding window. Throws VoterRateLimitException when exceeded.
// config/poller.php 'voter_rate_limit' => [ 'enabled' => true, 'max_votes' => 30, 'per_minutes' => 60, ],
Translatable Content
Store poll/option title and description as JSON locale maps. Returns the value for app()->getLocale() automatically, falls back to fallback_locale.
// config/poller.php 'translatable' => [ 'enabled' => true, 'fallback_locale' => 'en', ],
// Create with translations Poller::create([ 'title' => ['en' => 'Best framework?', 'tr' => 'En iyi framework?', 'az' => 'Ən yaxşı framework?'], ], $user); // Read in current locale app()->setLocale('tr'); $poll->title; // "En iyi framework?" // Translation helpers $poll->translate('title', 'az'); // "Ən yaxşı framework?" $poll->setTranslation('title', 'tr', 'Yeni başlık')->save(); $poll->getTranslations('title'); // ['en' => '...', 'tr' => '...', 'az' => '...']
Query Scopes
Search and filter polls with chainable scopes:
use Aftandilmmd\Poller\Models\Poll; use Aftandilmmd\Poller\Enums\PollStatus; use Aftandilmmd\Poller\Enums\PollType; Poll::query() ->search('framework') // matches title or description ->ofStatus(PollStatus::Active) // enum or string ->ofType(PollType::SingleChoice) ->createdBy($user->id) ->withinDateRange(now()->subMonth(), now()) ->get();
Error Handling
All voting errors throw typed exceptions:
use Aftandilmmd\Poller\Exceptions\PollClosedException; use Aftandilmmd\Poller\Exceptions\AlreadyVotedException; use Aftandilmmd\Poller\Exceptions\InvalidSelectionException; use Aftandilmmd\Poller\Exceptions\UnauthorizedVoteException; use Aftandilmmd\Poller\Exceptions\CustomOptionException; try { Poller::castVote($poll, $user, $optionId); } catch (PollClosedException $e) { // Poll is not accepting votes } catch (AlreadyVotedException $e) { // User already voted (and vote_change is disabled) } catch (InvalidSelectionException $e) { // Wrong number of selections or invalid option } catch (UnauthorizedVoteException $e) { // User's canVote() returned false } catch (CustomOptionException $e) { // Custom options not allowed, limit reached, or unauthorized }
Enums
use Aftandilmmd\Poller\Enums\PollType; use Aftandilmmd\Poller\Enums\PollStatus; PollType::SingleChoice->value; // "single_choice" PollType::SingleChoice->label(); // "Single Choice" PollType::SingleChoice->color(); // "green" PollType::options(); // ["yes_no" => "Yes/No", ...] PollType::enabled(); // Only config-enabled types PollStatus::Active->value; // "active" PollStatus::Active->label(); // "Active" PollStatus::Active->color(); // "green"
Extending
Custom Models
Override model classes in config:
'models' => [ 'poll' => App\Models\CustomPoll::class, 'option' => App\Models\CustomPollOption::class, 'vote' => App\Models\CustomPoller::class, ],
Custom Events
Replace event classes or disable them:
'events' => [ 'vote_cast' => App\Events\CustomVoteCast::class, 'poll_created' => null, // Disabled ],
Translations
The package includes translations for English, Turkish, and Azerbaijani. To customize or add new languages:
php artisan vendor:publish --tag=poller-translations
Translation files will be published to lang/vendor/poller/.
Testing
composer install vendor/bin/pest
Roadmap
Shipped
- Core CRUD with 5 poll types (yes/no, single, multiple, rating, ranked)
- Anonymous voting, vote changing, vote retraction
- Scheduled polls with auto-open / auto-close commands
- User-suggested custom options with limits
- Vote comments and required-comment polls
- Result percentages, leading option, detailed results export
- REST API (18 endpoints)
- Livewire components (Manager, Form, Display, Vote, Results)
- Trait-based authorization (
InteractsWithPolls,HasPolls) - 7 lifecycle/voting events with broadcasting support
- Pollable morph (attach polls to any model)
- Soft deletes
- Result caching with auto-invalidation
- Voter rate limiting (cross-poll sliding window)
- Translatable title/description (opt-in JSON locale map)
- Query scopes:
search,ofStatus,ofType,createdBy,withinDateRange - API filter parameters (
search,status,type,created_by,from,to) - API returns
429on voter rate limit - Localized exception messages (en, tr, az)
- Laravel 11, 12, 13 support
Considered for future
- Translatable form fields in Livewire
PollForm(multi-locale inputs) -
PollResource@withTranslationsfor API multi-locale output - CSV / JSON export beyond
array - IP-based vote tracking (anonymous spam protection)
- Built-in tags / categories
- First-party Filament / Nova plugin
Out of scope
These belong in user code or sibling packages, not this one:
- Notifications (mail / database / broadcast on poll events) — wire your own listeners
- Captcha / spam middleware — apply at the route level
- Webhooks — listen to events and POST yourself
- Charts / analytics dashboard — render from
getDetailedResults()data - Audit log — use
spatie/laravel-activitylogon the events - Short URLs / QR codes — use a dedicated package
License
MIT