wp-plugin-options-storage — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited wp-plugin-options-storage (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Where to put the data the plugin owns. WordPress offers several storage primitives — wp_options, four flavors of *_meta, transients, multisite site options/transients, and custom tables — and picking the right one is the single highest-leverage architectural decision for a plugin's long-term performance and maintainability.
This skill covers picking + using them correctly. It does NOT cover one-time activation seeding (see wp-plugin-lifecycle) or REST endpoint validation of stored values (see wp-rest-api).
This skill's author works on single-site WordPress; the multisite advice below is derived from WP source code but has not been end-to-end tested in a multisite environment. The primitives — get_site_option / update_site_option / set_site_transient / delete_site_option — exist and are documented; their semantics here are taken from wp-includes/option.php. If you ship a plugin that has actual multisite users, run an integration test on a real network install before relying on these patterns. Some quirks (switch_to_blog interactions, network admin context detection, blog-id-aware caches) only surface in a real network.
Trigger when ANY of the following is true:
update_option calls — performance smell.SELECT option_name, option_value FROM wp_options WHERE autoload IN (...)).| Need | Use | Key API |
|---|---|---|
| Site-wide config, settings page values, feature flags | wp_options (single grouped row) | get_option / update_option |
| Per-user data (preferences, dismissed notices; secrets need extra care) | user-meta | get_user_meta / update_user_meta |
| Per-post / CPT entry data | post-meta | get_post_meta / update_post_meta |
| Per-taxonomy-term data | term-meta | get_term_meta / update_term_meta |
| Per-comment data | comment-meta | get_comment_meta / update_comment_meta |
| Cached value with TTL (API response, computed result) | transient | get_transient / set_transient |
| Network-wide setting in multisite | site option | get_site_option / update_site_option |
| Network-wide cached value in multisite | site transient | get_site_transient / set_site_transient |
| Many rows with structured fields, queryable, aggregable | custom table | dbDelta + $wpdb->insert / $wpdb->get_results |
| Hot-path counter / metric updated many times per second | custom table OR object cache | $wpdb->query |
The rough rule: scalar or grouped key/value with no querying needs → option / meta / transient. Multi-row data you'll filter, sort, aggregate, or index → custom table.
The biggest single mistake in WP plugin storage: one option per setting.
// WRONG — 12 rows, 12 writes, 12 cache/autoload entries
update_option( 'myplugin_provider', $provider );
update_option( 'myplugin_default_model', $model );
update_option( 'myplugin_max_tokens', $tokens );
update_option( 'myplugin_log_enabled', $log );
update_option( 'myplugin_failure_mode', $mode );
// ... eight more// RIGHT — one row, one write, one cache/autoload entry
update_option( 'myplugin_settings', array(
'provider' => $provider,
'default_model' => $model,
'max_tokens' => $tokens,
'log_enabled' => $log,
'failure_mode' => $mode,
// ... eight more
) );When to group:
get_option call returns everything. This is not a compare-and-swap primitive, so concurrent read-modify-write flows can still race.myplugin_billing_settings, myplugin_email_settings, myplugin_ai_settings). Groups by domain, not by lump.wp-config.php constants / an encryption layer.When NOT to group:
myplugin_settings array race each other (read-modify-write without atomic CAS — see wp-plugin-cron for the concurrency story). Counters live in their own option (single scalar value) or a custom table.WP auto-serializes the array via maybe_serialize (wp-includes/functions.php) using PHP serialize(). get_option auto-maybe_unserializes back. You don't manually JSON-encode.
autoload controls whether the option is loaded into memory on every WordPress page request. Verified in add_option docblock (@since 6.6.0 The $autoload parameter's default value was changed to null, @since 6.7.0 The autoload values 'yes' and 'no' are deprecated):
// MODERN — let WP decide via default autoload heuristics
add_option( 'myplugin_settings', $defaults );
// EXPLICIT — autoload (option is read on most page loads)
add_option( 'myplugin_settings', $defaults, '', true );
// EXPLICIT — DO NOT autoload (option is rarely read; saves memory)
add_option( 'myplugin_uninstall_log', $defaults, '', false );Rules:
true / false (or pass null to let WP decide).auto, auto-on, or auto-off; by default auto and auto-on are treated as autoloaded values.update_option( $name, $same_value, false ) returns early and will not change autoload. On WP 6.7+, use wp_set_option_autoload( $name, false ); for older supported WP versions, change autoload when the value changes or recreate the option deliberately during a migration.Audit your plugin's autoload footprint with:
SELECT option_name, LENGTH(option_value)
FROM wp_options
WHERE autoload IN ('yes', 'on', 'auto-on', 'auto')
AND option_name LIKE 'myplugin_%';(WP 6.6+ uses values like 'on', 'off', 'auto', 'auto-on', and 'auto-off'; pre-6.6 used 'yes' / 'no'.)
"I'll just JSON-encode this nested data and update_option it."This works but it's almost always the wrong choice for non-trivial data in WordPress. The trade-off applies whether you store via PHP serialize() (WP's auto-pathway when you pass an array) OR manually as wp_json_encode($data) — the underlying database column is LONGTEXT, opaque to the SQL engine.
What you lose:
data->'$.user_id' from your option. Looking up "all options where user_id = 42" means fetching every row, decoding in PHP, filtering. O(n) regardless of data size.SUM(price) / AVG(score) / GROUP BY status over fields inside the blob is impossible without per-row decode.When the blob is fine:
When you should reach for a custom table instead:
The custom-table path is a dbDelta call in activation (see wp-plugin-lifecycle) plus $wpdb->prepare for queries. Not a free lunch but pays dividends every time you need to touch the data.
Transients store a value with an optional TTL. Backed by the object cache when one is available (Redis, Memcached, etc.); fall back to wp_options otherwise.
$status = get_transient( 'myplugin_api_status' );
if ( false === $status ) {
$status = myplugin_check_api_status();
set_transient( 'myplugin_api_status', $status, HOUR_IN_SECONDS );
}Rules:
update_option() with an explicit autoload choice instead.set_transient( 'api_status', ... ) collides with everything; myplugin_api_status is safe.wp_cache_set / wp_cache_get.myplugin_settings, myplugin_billing_settings. Keep under ~64 chars (option_name column is varchar(191) in modern MySQL but transient timeout names need 12+ chars of overhead).custom-fields metabox by default) prefix with underscore: _myplugin_form_settings. The leading underscore matters — register_post_meta with a _-prefixed key requires explicit auth_callback for REST writes._transient_<name> and _transient_timeout_<name> internally — set_transient() names must be 172 characters or fewer.set_site_transient() names must be 167 characters or fewer.{$wpdb->prefix}myplugin_<entity> — never hardcode wp_ since $wpdb->prefix may be customized. Multisite uses per-blog prefix automatically; for network-wide tables use $wpdb->base_prefix.false for rarely-read options. Don't pass 'yes'/'no' strings on WP 6.7+.wp-config.php constants. Non-autoload is not encryption; it only keeps the secret out of the alloptions payload. (See wp-security-secrets.)// WRONG — one option per setting, 30 rows, 30 autoload entries
foreach ( $settings as $key => $value ) {
update_option( 'myplugin_' . $key, $value );
}
// WRONG — JSON-encoded blob storing 10,000 log entries
update_option( 'myplugin_logs', wp_json_encode( $log_entries ) );
// Reading back: get_option, json_decode, paginate in PHP, repeat
// Should be: custom table with id / created_at / level / message columns
// WRONG — transient as durable storage (no TTL)
set_transient( 'myplugin_user_purchases', $rows ); // no expiration
// DB fallback autoloads it; object cache flush can still drop it
// WRONG — per-user data in a single option
$users = get_option( 'myplugin_users', array() );
$users[ $user_id ]['last_seen'] = time();
update_option( 'myplugin_users', $users );
// race condition + linear scan + autoload bloat
// RIGHT
update_user_meta( $user_id, 'myplugin_last_seen', time() );
// WRONG — deprecated 'yes'/'no' strings on WP 6.7+
add_option( 'myplugin_settings', $defaults, '', 'yes' );
// RIGHT
add_option( 'myplugin_settings', $defaults, '', true );
// WRONG — trying to change autoload while keeping the same value
update_option( 'myplugin_large_report', get_option( 'myplugin_large_report' ), false );
// RIGHT on WP 6.7+
wp_set_option_autoload( 'myplugin_large_report', false );add_option on activation, and delete_option / delete_site_option on uninstall.Schema / Constants centralization pattern that names every option key in one place.update_option) — niche.wp option get, wp option update) — adjacent topic.add_option autoload semantics (WP 6.6 null default, 6.7 'yes'/'no' deprecation): wp-includes/option.phpmaybe_serialize (WP's auto-PHP-serialize for arrays/objects): wp-includes/functions.phpset_transient / set_site_transientget_metadata / update_metadata / delete_metadata underlie all *_meta functions~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.