Spotlight Posts
Editorial featured-posts plugin built to WordPress VIP standards — object caching, primary-key queries, 106 integration tests, clean WordPress-VIP-Go lint. Query Loop variation, dynamic block, REST API.
by Rajkiran Bagal · github.com/rajkiranbagal/spotlight-posts · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/rajkiranbagal/spotlight-posts/archive/refs/heads/main.zipReadme
Spotlight Posts
A WordPress plugin that lets editors flag posts as featured — from the editor, the posts list, bulk actions or Quick Edit — and surfaces them three ways: a variation of core's Query Loop, a dedicated dynamic block, and a public REST endpoint.
Not affiliated with WordPress VIP or Automattic. The name and the
vip_prefixes refer to the platform this targets, not to its authorship. It is independent work, built to the standards a VIP engagement depends on: object caching, primary-key queries, disciplined escaping and sanitization, nonce plus capability checks, and a cleanWordPress-VIP-Gorun.
What that rests on: 106 integration tests against a real WordPress install · CI on PHP 8.1 / 8.2 / 8.3 and WordPress trunk · zero PHPCS findings · every feature exercised end to end in a VIP local environment.
It has not run against VIP production traffic, so read "VIP-ready" as built to the standard and verified locally, not as proven at scale.
- Requires: WordPress 6.4+, PHP 8.1+
- Text domain:
spotlight-posts - Meta key:
_spotlight_featured
📘 docs/VIP-GUIDE.md — a full walkthrough of how the VIP platform works end to end: repo anatomy, the request lifecycle, the local environment, this plugin file by file, and how to test it all on localhost.
What it does
Editors can flag a post four ways, because different jobs want different ones:
| Where | For |
|---|---|
| Featured checkbox in the post sidebar | Editing a single post |
| Star toggle in the posts list table | Changing one post without opening it |
| Mark as featured bulk action | Curating many at once |
| Quick Edit checkbox | Alongside the other inline fields |
A Featured / Not featured filter sits above the list table, and the column appears in Screen Options so anyone who does not curate can hide it.
A post can also be featured until a given date and time, set alongside the checkbox in the editor. See scheduled expiry for how that interacts with caching.
Posts → Featured Order arranges the list by dragging, or with keyboard move controls
for anyone not using a mouse. That order is what the block and the REST endpoint return.
Curation is gated on edit_others_posts rather than post authorship, filterable via
spotlight_posts_manage_capability — deciding what the homepage promotes and being
able to write posts are different jobs.
Out of the box this applies to posts. Any post type can opt in through the
spotlight_posts_post_types filter, and they share one ordered list rather than being
grouped by type.
Flagged posts appear in three places:
- a Featured Posts variation of core's Query Loop — full card layouts with featured images, titles, excerpts and dates, styled by your theme,
- the Featured Posts block (dynamic, server-rendered, configurable heading and count), and
GET /wp-json/spotlight/v1/posts?count=5
All three read the same index and apply the same expiry rule, so they cannot disagree about what is featured.
Setup
composer install # PHPCS + WordPress VIP coding standards
npm install # @wordpress/scripts build toolchain
npm run build # compiles blocks/ -> build/
src/ is PHP and blocks/ is JavaScript — deliberately separated, because PSR-4 wants
PHP in src/ while wp-scripts defaults to src/ for JavaScript, and a directory
holding both tells you nothing at a glance. --webpack-src-dir=blocks redirects the
bundler.
npm run build produces two entry points: build/featured-list for the dedicated block,
and build/query-loop for the Query Loop variation. They are separate wp-scripts runs
because wp-scripts build only discovers entries from block.json, and a block variation
has no block.json of its own.
Building is required before activating the plugin. Both registrations deliberately no-op when their build output is missing, so a fresh clone that has not been built yet degrades to "block absent" rather than a fatal error.
Commands
| Command | Purpose |
|---|---|
composer lint |
PHPCS against WordPress-VIP-Go |
composer lint:fix |
PHPCBF auto-fix pass |
composer test |
PHPUnit integration suite |
npm run build |
One-off production build |
npm run start |
Watch mode for development |
Running the tests
The suite runs against a real WordPress install, not mocks — the things worth testing here
are cache behaviour, WP_Query results and REST dispatch, none of which survive being
mocked.
bin/install-wp-tests.sh wordpress_test <db-user> <db-pass> <db-host> 6.7
composer test
If you are using the VIP dev environment, its MySQL is already exposed on the host — take
the port from docker ps and pass --skip-db-creation style arguments as needed:
bin/install-wp-tests.sh wordpress_test wordpress wordpress 127.0.0.1:50400 6.7 true
WP_TESTS_DIR=/tmp/wordpress-tests-lib composer test
127 tests cover the save guards, the count clamp, REST validation, cache invalidation, index ordering, the draft round-trip, the list-table controls, the ordering screen, scheduled expiry, the Query Loop variation, multi-post-type support and the block's heading levels. They have been mutation-checked: removing the capability check, the meta-key filter or the count clamp each turns the suite red.
Running it in a VIP local environment
Verified against VIP-CLI 4.1.0 with the plugin loaded through a local checkout of
Automattic/vip-go-skeleton, so
the layout matches a real VIP application.
git clone --depth 1 https://github.com/Automattic/vip-go-skeleton.git ~/vip-skeleton
rsync -a --exclude .git --exclude node_modules --exclude vendor \
./ ~/vip-skeleton/plugins/spotlight-posts/
vip dev-env create --slug vip-featured --title "VIP Featured" \
--multisite false --php 8.2 --wordpress 6.7 \
--app-code ~/vip-skeleton --mu-plugins demo \
--elasticsearch n --phpmyadmin n --xdebug n --cron n --mailpit n --photon n
vip dev-env start --slug vip-featured
vip dev-env exec --slug vip-featured -- wp plugin activate spotlight-posts
vip dev-env exec --slug vip-featured -- wp theme activate twentytwentyfive
Two things worth knowing:
- Use
--app-code <path>, not--app-code demo. Thedemovalue mounts a read-only image of the skeleton, so there is nowhere to put your plugin. A local clone is writable and otherwise identical. - Pin WordPress to 6.7 or later for the skeleton. The skeleton bundles the
twentytwentyfivetheme, which requires WP 6.7. On WP 6.4 it cannot activate, no theme renders, and every front-end request returns HTTP 200 with an empty body — which looks like a plugin fault but is not one. The plugin itself supports 6.4+; only the skeleton's theme forces the higher floor.
Because the plugin is copied rather than symlinked (Docker bind mounts do not
follow symlinks out of the mount), re-run the rsync after editing, and remember
that build/ must exist in the copy for the block to register.
Architecture
Every class implements a small Registrable contract and declares its own hooks.
Plugin is the composition root: it builds the object graph once and lets each service
attach itself. The main plugin file registers no hooks at all — it holds a plugin
header, a PSR-4 autoloader and one call to Plugin::boot().
Dependencies arrive through constructors. Nothing reaches out for a service it was not given, which is what keeps the graph readable:
Cache, PostTypes, Request depend on nothing
Index, Schedule depend on Cache + PostTypes
Repository depends on Index, Schedule, Cache, PostTypes
Frontend / Admin / Rest / Cli depend on Repository or Index
That direction is the point. Cache invalidation used to live inside the index and the
scheduler, so both called into the query module while it called back into them — two
circular dependencies. Nothing was broken, because PHP resolves those at call time, but
neither module could be reasoned about or tested in isolation. Extracting Support\Cache
gave all three a collaborator they depend on one way only.
What was deliberately not done
Wrapping the existing functions in static classes would have added ceremony and kept both
cycles. There are no static-only classes here, no interfaces with a single
implementation, and no getters around plain data. Registrable earns its place because a
dozen classes implement it; FeaturedPost earns its because an array shape documented in
a docblock is enforced by nothing.
Plugin::instance() exists for exactly two callers — the activation hook, which runs
before anything holds a reference, and the test suite, which is a composition root of its
own. It is not a general-purpose service locator, and application code does not use it.
VIP-relevant design decisions
These are the parts worth reviewing.
Caching: versioned keys, not key enumeration
Support\Cache stores each result set under a key that embeds a cache version
integer:
featured_v<version>_n<count>
Invalidation is wp_cache_incr() on that single version key. Every previously cached
permutation is orphaned at once and ages out on its own.
It fires from two directions. Every index mutation routes through Index::set(), which
flushes as it writes — so featuring a post through the meta box, a bulk action, Quick
Edit, the row toggle, WP-CLI or the REST meta API all converge on the same invalidation.
Separately, save_post covers edits that change what the list renders without changing
who is in it: a retitled post, a new excerpt, a draft going live.
This matters on VIP specifically. The object cache is shared and remote, and it
offers no "delete by prefix" primitive — so the alternative is tracking and
deleting every n permutation by hand, which is both racy and chatty. One
incr() is atomic and costs a single round trip.
The 300-second TTL is only a backstop against a stale entry outliving a cache
reset; the version bump is what provides correctness. The TTL is written as a
literal rather than a named constant so VIP's LowExpiryCacheTime sniff can
statically verify it clears the 300s floor.
The query is bounded on every axis
'posts_per_page' => $number_of_posts, // clamped to 1..10
'no_found_rows' => true, // skips SQL_CALC_FOUND_ROWS
'update_post_term_cache' => false, // terms are never rendered
'ignore_sticky_posts' => true, // no second query for stickies
The count is clamped before it reaches the cache key, so an attacker cannot
spray ?count= values to blow out the cache with distinct entries.
Why WP_Query rather than a convenience wrapper
get_posts() and query_posts() are the two easy answers, and both are wrong here.
query_posts() clobbers the main query global and forces a re-query — it is
disqualified on any site, VIP or not. get_posts() is closer, but it silently
sets suppress_filters => true, which means caching and query plugins in the
stack never see the query — on VIP that quietly bypasses platform-level query
behaviour. It also gives no clean way to set no_found_rows.
WP_Query is explicit: every performance flag above is visible at the call site,
which is exactly what a reviewer needs to audit.
The read path never searches by meta
A meta_key / meta_value lookup is unindexed — wp_postmeta has no composite index
that makes it selective — so it trips WordPress.DB.SlowDBQuery. Rather than suppress
that warning on the hot path, the plugin stops asking the database to find featured
posts at all and maintains the answer on write.
| Layer | Role |
|---|---|
_spotlight_featured post meta |
Source of truth, per post |
spotlight_featured_post_ids option |
Ordered index of IDs, maintained on every meta write |
wp spotlight rebuild |
Regenerates the index from meta |
The resulting query, captured from the running site:
SELECT wp_posts.* FROM wp_posts
WHERE 1=1 AND wp_posts.ID IN (7,5)
AND wp_posts.post_type = 'post'
AND ((wp_posts.post_status = 'publish'))
ORDER BY FIELD(wp_posts.ID,7,5) LIMIT 0, 5
No wp_postmeta join. A primary-key lookup with explicit ordering.
Three consequences worth noting:
- Ordering is free. The index is an ordered array, so newly featured posts lead and an editor can impose any order without a second storage mechanism.
- Publication state is not the flag. Posts are indexed in any non-trashed status and
filtered to
publishat read time, so a post going back to draft keeps its position and reappears where it was when republished. - The index is disposable. It is derived from meta, so a lost update self-heals via rebuild rather than becoming corruption — which is what makes the small race window on concurrent option writes an acceptable trade.
The cap of 100 IDs is a platform constraint, not a product one. The option is autoloaded,
so it lands in alloptions on every request; VIP runs an alloptions-limit mu-plugin
precisely because oversized autoloaded options degrade every page load.
One suppression remains, in Index::rebuild() — the one place that still has to
search by meta. It runs on activation and on demand via WP-CLI, never on a front-end
request.