Imported from Lonsdale201/wp-agent-skills (
plugin-scaffold/wp-plugin-options-storage/SKILL.md). Install upstream withnpx skills add Lonsdale201/wp-agent-skills --skill wp-plugin-options-storage. Copyright stays with the author.
WordPress plugin: options & storage
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).
Multisite caveat (read first)
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.
When to use this skill
Trigger when ANY of the following is true:
- Scaffolding a new plugin's settings page or any persistent state.
- Reviewing a plugin where you see hundreds of
update_optioncalls — performance smell. - Picking where to store a piece of data: option vs meta vs transient vs custom table.
- Investigating a slow autoload payload (
SELECT option_name, option_value FROM wp_options WHERE autoload IN (...)). - The user asks "should I JSON this and put it in an option" — short answer below, see "JSON storage trap".
Decision matrix — pick by access pattern
| 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.
Group coherent settings; separate independent state
Avoid mechanically creating one option for every form field, but do not replace that smell with one universal blob. Choose boundaries by read/write lifecycle.
// SMELL when these fields are always read and saved together.
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
// GOOD when this is one coherent settings contract.
update_option( 'myplugin_settings', array(
'provider' => $provider,
'default_model' => $model,
'max_tokens' => $tokens,
'log_enabled' => $log,
'failure_mode' => $mode,
// ... eight more
) );
When to group:
- All settings UI values that belong to one feature, in one associative-array option. One form save becomes one database write; one
get_optioncall returns everything. This is not a compare-and-swap primitive, so concurrent read-modify-write flows can still race. - Distinct features can each have their own option (
myplugin_billing_settings,myplugin_email_settings,myplugin_ai_settings). Groups by domain, not by lump. - Repeating-row config (e.g. a list of webhook URLs) can be the array value inside one option.
- Secrets are the exception. Do not bury API keys or OAuth tokens inside a normal grouped settings option that may autoload. Store them separately with explicit non-autoload, or prefer
wp-config.phpconstants / an encryption layer.
When NOT to group:
- Counters / increments updated by independent code paths. Two requests writing the same settings array race. A separate scalar option limits the collision domain but still is not an atomic increment; use it only for single-writer or best-effort state. Correct concurrent counters need an atomic custom-table update or a backend whose increment primitive is guaranteed.
- Independent settings with different write cadence, capability, autoload, secret, or migration requirements. A few intentional scalar options are clearer and safer than a shared read-modify-write blob.
- Cached values with different TTLs — those are transients, not options.
- Per-user / per-post data — wrong primitive, use the right meta API.
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 management — WP 6.6+ semantics
autoload controls whether the option is loaded into memory on every WordPress page request. Verified in the add_option docblock at wp-includes/option.php (@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:
- WP 6.7+ deprecates the string values
'yes'/'no'. Use the booleantrue/false(or passnullto let WP decide). - Default to
null(auto-decide) for small settings read in normal runtime paths. In WP 6.6+, the default path stores an internal value such asauto,auto-on, orauto-off; by defaultautoandauto-onare treated as autoloaded values. - Force
falsefor options that are only read on specific admin pages, REST endpoints, or background jobs. A 500KB serialized config that's only read on the settings page should NOT be in autoload. - Force
trueonly when the option is genuinely needed on most page loads (rare for plugin settings). The site's autoload payload is shared across all plugins; bloating it slows everything down. - Changing autoload on an existing option is a separate operation.
update_option( $name, $same_value, false )returns early and will not change autoload. On WP 6.4+, usewp_set_option_autoload( $name, false )or batch withwp_set_option_autoload_values(). 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'.)
The JSON / serialized-blob trap
"I'll just JSON-encode this nested data and
update_optionit."
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:
- No SQL indexing on inner fields. MySQL can't use an index on
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. - No aggregation.
SUM(price)/AVG(score)/GROUP BY statusover fields inside the blob is impossible without per-row decode. - No partial update. Want to bump one counter inside the array? Read whole option, decode, mutate one field, encode, write whole option back. Concurrent writes race.
- Painful schema migration. Renaming a key or splitting a field means iterating every row, decoding, mutating, encoding, writing. Multiply by how many sites the plugin runs on.
- Cache pressure. A 500KB serialized option in autoload bloats every page request's memory.
When the blob is fine:
- Settings UI values (a dozen scalars in one array, ≤ 4-8 KB total). Fetched once per request, never aggregated.
- Read-mostly state that's effectively a "blob of preferences" — never queried by inner fields.
When you should reach for a custom table instead:
- Logs, audit trails, anything append-mostly.
- Per-record entities with their own schema (e.g. webhook deliveries, AI request history, user activity).
- Anything you'll ever want to filter, sort, aggregate, paginate.
- Big rows (≥ 50KB) — at that point, performance and migration concerns dominate.
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 — caching, not storage
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:
- Transients are CACHE, not source-of-truth. WP may evict them at any time (object cache flush, low memory). Don't store anything you can't recompute.
- TTL > 0, almost always. With the database fallback, a transient with no expiration is stored as an autoloaded option. If the value is durable state, use
update_option()with an explicit autoload choice instead. - Name your transients with a plugin prefix.
set_transient( 'api_status', ... )collides with everything;myplugin_api_statusis safe. set_site_transientfor multisite-network-wide caches (verified, untested in this skill's authoring env — see caveat above).- Don't use transients for high-write counters. Each set/get traverses the object cache layer; for hot paths, write to a custom table or use the object cache directly via
wp_cache_set/wp_cache_get.
Naming conventions
- Option names: snake_case, plugin-prefixed.
myplugin_settings,myplugin_billing_settings. Keep under ~64 chars (option_name column isvarchar(191)in modern MySQL but transient timeout names need 12+ chars of overhead). - Meta keys: snake_case, plugin-prefixed; for "private" meta (not shown in REST or
custom-fieldsmetabox by default) prefix with underscore:_myplugin_form_settings. The leading underscore matters —register_post_metawith a_-prefixed key requires explicitauth_callbackfor REST writes. - Transient names: snake_case, plugin-prefixed. WordPress prepends
_transient_<name>and_transient_timeout_<name>internally —set_transient()names must be 172 characters or fewer. - Site option / site transient names: same conventions, just on the network table.
set_site_transient()names must be 167 characters or fewer. - Custom table names:
{$wpdb->prefix}myplugin_<entity>— never hardcodewp_since$wpdb->prefixmay be customized. Multisite uses per-blog prefix automatically; for network-wide tables use$wpdb->base_prefix.
Critical rules
- Group settings that form one read/write contract. Keep independently updated or differently protected state separate; avoid both 100 accidental scalar rows and one race-prone universal blob.
- Default
autoloadtonull(let WP decide). Forcefalsefor rarely-read options. Don't pass'yes'/'no'strings on WP 6.7+. - For queryable / aggregable / append-mostly data, use a custom table. JSON / PHP-serialized blobs in options can't be SQL-indexed.
- Transients are cache, not storage. Always TTL, always plugin-prefixed.
- Use the right primitive for the entity scope — site (option), user (user-meta), post (post-meta), etc. Don't fake user-data in a global option keyed by user ID.
- Plugin-prefix every name (option, meta, transient, custom table, hook).
- Never autoload secrets. API keys and tokens belong in non-autoload options or, ideally,
wp-config.phpconstants. Non-autoload is not encryption; it only keeps the secret out of the alloptions payload. (Seewp-security-secrets.)
Common mistakes
// SMELL — unbounded field-to-option expansion with no storage contract.
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 );
Cross-references
- Run
wp-plugin-lifecyclefor default option seeding viaadd_optionon activation, anddelete_option/delete_site_optionon uninstall. - Run
wp-settings-storage-auditwhen reviewing a full settings contract: option array shape, Settings API registration, defaults, autoload, REST exposure, Customizer boundary, update hooks, and deprecations. - Run
wp-security-secretswhen the option holds API keys, tokens, OAuth secrets — autoload + plaintext storage warrants additional thought. - Run
wp-plugin-architecturefor theSchema/ Constants centralization pattern that names every option key in one place.
What this skill does NOT cover
- Custom table schema design beyond "if you need it, use one" — column types, indexes, partitioning, migrations across plugin versions are a separate topic.
- Object-cache backend setup (Redis / Memcached) — server-side concern.
- Encrypted-at-rest options (per-plugin encryption layer over
update_option) — niche. - Multisite end-to-end testing patterns — see caveat at top.
- WP-CLI commands for option management (
wp option get,wp option update) — adjacent topic.
References
add_optionautoload semantics (WP 6.6nulldefault, 6.7'yes'/'no'deprecation):wp-includes/option.phpmaybe_serialize(WP's auto-PHP-serialize for arrays/objects):wp-includes/functions.php- Transient API:
wp-includes/option.phpset_transient/set_site_transient - Meta API:
wp-includes/meta.php—get_metadata/update_metadata/delete_metadataunderlie all*_metafunctions - WP database schema:
wp-admin/includes/schema.php— see how WP itself names tables and columns for inspiration - Official documentation: https://developer.wordpress.org/reference/functions/add_option/
- Official documentation: https://developer.wordpress.org/reference/functions/get_option/
- Official documentation: https://developer.wordpress.org/reference/functions/get_post_meta/
- Official documentation: https://developer.wordpress.org/reference/functions/set_transient/