ZuidWest Poll
WordPress-plugin voor anonieme polls via shortcodes en de Interactivity API.
by Streekomroep ZuidWest · github.com/oszuidwest/zw-poll · website
Install
The author publishes release zips, so WP-CLI can install straight from GitHub:
wp plugin install https://github.com/oszuidwest/zw-poll/releases/download/0.4.0/zw-poll-0.4.0.zipReadme
ZuidWest Poll
A WordPress plugin for anonymous, single-choice polls. Polls are embedded in posts and pages with a shortcode, rendered server-side for page-cache compatibility, and made interactive with the WordPress Interactivity API.
Requirements
| Component | Version |
|---|---|
| WordPress | 7.1+ |
| PHP | 8.3+ |
| Node.js | 20+ locally, 24 in CI |
Node.js is only required for linting, WordPress Playground, and Playwright. The
frontend and admin assets in src/ ship without a build step.
Getting started
composer install
npm install
npm run playground
The demo runs at http://127.0.0.1:9400. The
Playground blueprint activates the plugin and
Classic Editor, then creates an open and a closed demo poll:
/poll-demo//poll-gesloten-demo/
To install the plugin in another WordPress environment, build an uploadable ZIP with a SHA-256 checksum:
npm run build:plugin
The generated files are written to dist/. The version is read from
zw-poll.php.
Usage
- Create a poll under Polls and provide a question, answer options, and a status.
- Copy the shortcode from the Embed panel or the poll list.
- Add it to a post or page:
[zw_poll id="123"]
Classic Editor also provides an Insert poll button in the visual toolbar and a compact poll button in text mode. The poll title is the question shown to readers. Option IDs remain stable when labels are renamed, so existing votes remain valid.
Each answer can optionally use an image from the WordPress media library. The card layout is enabled only when every answer has a valid image attachment; partial or stale image sets safely retain the existing text layout. Two and four answers use two columns, other counts use three columns, and narrower screens drop to two and then one column. Images are decorative because the adjacent answer label already provides the accessible name.
Shortcode usage is listed under Used in and is taken into account by the delete guard.
Settings
Administrators can configure vote protection, proxy headers, and uninstall data
removal under Polls → Settings. Settings are stored per site in
zw_poll_settings.
- Rate limiting can be disabled and defaults to 30 requests per 60 seconds. Changes apply to newly created transient windows.
- Supported proxy headers are
CF-Connecting-IP,X-Forwarded-For, andX-Real-IP. Azw_poll_client_ipfilter in code takes precedence. - Removing data on uninstall is opt-in. By default, the votes table, salt, settings, and capabilities are retained.
- Percentages are always displayed. The numeric vote total is shown after 100 votes by default; administrators can change the site-wide threshold from 0 to 1,000,000. Each poll can use that default, always hide the total, or always show it.
The visibility decision is rendered on the server and mirrored by the Interactivity API state. Cached pages therefore keep the configured policy, while the total appears immediately when a newly submitted vote reaches the threshold.
WP-CLI and REST API
wp zw-poll list
wp zw-poll close-expired
wp zw-poll rebuild <poll_id>
wp zw-poll reset <poll_id> --yes
Editors can set an end date and time for each poll in the Planning meta box. Input and display use the WordPress site timezone; storage uses a UTC timestamp. An idempotent WP-Cron sweep runs every five minutes and closes published, open polls after their deadline. Drafts, trashed polls, polls without a deadline, and polls already closed are left unchanged.
WP-Cron is request-driven, so a vote may still be accepted between the deadline
and the next sweep. Sites that need more precise closing can run
wp zw-poll close-expired from system cron. Consumers can listen to
zw_poll_closed to purge caches or perform other follow-up work; the action
receives the poll ID and its tracked content IDs. Use rebuild after manual
database changes or to restore the aggregate cache from the votes table.
REST endpoints:
- Public:
POST /wp-json/zw-poll/v1/vote - Editor/admin:
POST /wp-json/zw-poll/v1/poll/{id}/reset - Poll management: standard custom post type routes under
/wp-json/wp/v2/zw-polls
The public vote endpoint intentionally does not use a REST nonce. Protection is provided by same-origin validation, schema validation, rate limiting, and a unique deduplication index.
Development and testing
composer test # PHPUnit
composer coverage # PHPUnit with a minimum of 80% line coverage
composer stan # PHPStan level 8
composer lint # PHPCS/WPCS
composer lint:fix # PHPCBF
composer security # Composer audit
npm run lint # JavaScript and CSS linting
npm run make-pot # Update languages/zw-poll.pot
The POT command extracts translatable strings from both PHP and JavaScript.
The frontend module registers the zw-poll text domain with WordPress' native
Script Modules API. Bundled translation JSON files placed in languages/ are
included in release ZIPs; generate them from translated PO files with
wp i18n make-json languages --no-purge. WordPress.org language packs remain
supported because core falls back to wp-content/languages/plugins when a
matching bundled catalog is absent.
Start Playground in a separate terminal before running the end-to-end tests:
npm run playground
npm run test:e2e
CI also runs WordPress Plugin Check, verifies the translation template, and runs Playwright against WordPress 7.1 and the latest WordPress release.
Architecture
The PHP code uses the ZuidWest\Poll\ namespace under src/ and is bootstrapped
through Plugin::boot(). Its main components are:
- Bootstrap and settings:
Plugin,Activation,Support\Capabilities, andSupport\Settings. - Polls and votes:
PostType\PollPostType,Vote\VoteRepository,Vote\AggregateCache, andVote\RateLimiter. - REST API and shortcode:
Rest\VoteController,Rest\AdminController, andShortcode\PollShortcode. - Frontend:
Frontend\PollRenderer,Frontend\Assets,src/Frontend/view.js, andsrc/Frontend/style.css. - Admin, cron, and CLI: the classes under
Admin\,Cron\PollCloseSweep, andCli\Commands.
Polls are stored as zw_poll posts. Votes are stored in
{$wpdb->prefix}zw_poll_votes; _zw_poll_aggregate holds cached counts per
option ID. The poll title is the question and is limited to 200 characters.
PollShortcode::render() delegates Interactivity API markup generation to
PollRenderer and applies server-side directive processing. Cached pages
therefore show results even without JavaScript. src/Frontend/view.js is a
native ES module that loads @wordpress/interactivity through the WordPress
import map. The derived state in PHP and the JavaScript getters must remain in
sync.
Security and privacy
The default protection is intended for informal polls behind page caches. Cookie tokens and same-origin headers limit ordinary duplicate votes and mistakes, but they do not provide cryptographic proof against a determined client. Do not use the plugin for binding elections without additional controls.
- The plugin does not store a WordPress user ID with a vote.
- Deduplication uses the functional
zwpoll_voted_{poll_id}cookie and a unique index on(poll_id, cookie_token). - IP addresses are hashed with HMAC-SHA256 and the server-side
zw_poll_ip_saltfor rate limiting and auditing. - SQL queries use
$wpdb->prepare()and output is escaped.
For more sensitive polls, use these filters to tighten protection:
add_filter( 'zw_poll_rate_limit', function (
array $limit,
string $ip_hash,
int $poll_id
): array {
return 123 === $poll_id
? [ 'max' => 5, 'window' => 5 * MINUTE_IN_SECONDS ]
: $limit;
}, 10, 3 );
add_filter( 'zw_poll_vote_allowed', function (
bool|WP_Error $allowed,
int $poll_id,
string $option_id,
WP_REST_Request $request
): bool|WP_Error {
if ( true !== $allowed ) {
return $allowed;
}
// Validate a WAF challenge or signed edge header here, for example.
return $allowed;
}, 10, 4 );
zw_poll_rate_limit can only make a poll more restrictive; the global limit
remains the upper bound. Use positive integers for max and window.
zw_poll_vote_allowed only allows the exact boolean value true. Return a
WP_Error with an explicit HTTP status for custom errors.
Reverse proxies
The plugin uses REMOTE_ADDR by default. Configure a proxy header only when a
trusted proxy overwrites incoming headers. For X-Forwarded-For, the plugin
uses the final value. With proxy chains, prefer a single-value edge header or a
custom filter:
add_filter( 'zw_poll_client_ip', function ( string $ip ): string {
// Verify that the request passed through a trusted proxy first.
return $_SERVER['HTTP_CF_CONNECTING_IP'] ?? $ip;
} );
Invalid IP addresses fall back to REMOTE_ADDR. Syntax validation alone does
not make a client header trustworthy. An edge rate limit on
POST /wp-json/zw-poll/v1/vote remains recommended.
Theming
The neutral default styles in src/Frontend/style.css use CSS custom properties
on .zw-poll. To set a custom accent color:
.zw-poll {
--zw-poll-accent: #c8102e;
}
Choose a color with sufficient contrast. The full token list, including header
and vote-button tokens, is documented at the top of
src/Frontend/style.css.
Release process
Manually start the release workflow from main. The version comes from the
Version: header in zw-poll.php. The workflow runs all quality
checks and publishes a ZIP with its checksum. Only zw-poll.php,
uninstall.php, LICENSE, README.md, src/, and languages/ are packaged.
The force option can recover a missing release for the current commit; an
existing release is never overwritten.
License
ZuidWest Poll is free software licensed under the GNU General Public License v2.0 or later.
Out of scope
The plugin does not provide multiple-choice or ranked polls, an export UI, email notifications, external embeds, A/B tests, a Gutenberg block, or a non-JavaScript form fallback.
Read the full README on GitHub →
Releases
| Tag | Published | Asset | Downloads |
|---|---|---|---|
| 0.4.0 | Sep 8, 2026 | zw-poll-0.4.0.zip | 0 |
| 0.4.0 | Sep 8, 2026 | zw-poll-0.4.0.zip.sha256 | 0 |
| 0.3.1 | Sep 7, 2026 | zw-poll-0.3.1.zip | 0 |
| 0.3.1 | Sep 7, 2026 | zw-poll-0.3.1.zip.sha256 | 0 |
| 0.3.0 | Sep 7, 2026 | zw-poll-0.3.0.zip | 1 |
| 0.3.0 | Sep 7, 2026 | zw-poll-0.3.0.zip.sha256 | 0 |
| 0.2.0 | Sep 6, 2026 | zw-poll-0.2.0.zip | 2 |
| 0.2.0 | Sep 6, 2026 | zw-poll-0.2.0.zip.sha256 | 1 |
| 0.1.1 | Sep 6, 2026 | zw-poll-0.1.1.zip | 1 |
| 0.1.1 | Sep 6, 2026 | zw-poll-0.1.1.zip.sha256 | 1 |
| 0.1.0 | Sep 6, 2026 | zw-poll-0.1.0.zip.sha256 | 1 |
| 0.1.0 | Sep 6, 2026 | zw-poll-0.1.0.zip | 2 |
| 0.0.1 | Sep 6, 2026 | zw-poll-0.0.1.zip.sha256 | 1 |
| 0.0.1 | Sep 6, 2026 | zw-poll-0.0.1.zip | 1 |