WordPress plugin: options & storage
SkillFiles & storagePicks the right WordPress storage primitive for plugin data: options, user/post/term/comment meta, transients, site options, site transients, or custom tables. Covers grouped settings, autoload management, transient TTL rules, serialized/JSON blob trade-offs, multisite storage caveats, and naming conventions. Use when scaffolding settings, choosing persistence for plugin-owned data, or auditing update_option/get_option/get_post_meta/ set_transient/autoload usage.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the WordPress plugin: options & storage skill
What this skill tells your AI
The instructions your AI receives, as published by lonsdale201/wp-agent-skills in plugin-scaffold/wp-plugin-options-storage/SKILL.md and read by ahel’s review.
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/
Signals
- GitHub stars
- 22
- Forks
- 2
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
wp-plugin-options-storage- Source
- github.com/lonsdale201/wp-agent-skills