Claude Skill

wp-metadata-api

Implements and audits WordPress post, user, term, comment, and

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download lonsdale201-wp-agent-skills-wordpress_wp-metadata-api-52f6020.zip · 4 KB
Part of lonsdale201/wp-agent-skills — 226 skills

Install

skills CLI npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/wordpress/wp-metadata-api
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart
Git git clone https://github.com/Lonsdale201/wp-agent-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lonsdale201/wp-agent-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

WordPress Metadata API

Choose the correct metadata surface and preserve its historical contracts. This skill covers correctness and data integrity, not whether a meta table is the right storage primitive for a new feature.

Choose the operation by identity

Intent API
Read one key's first value get_post_meta( $id, $key, true )
Read all rows for one key get_post_meta( $id, $key, false )
Add another row add_post_meta() / add_metadata()
Update every row matching object + key update_post_meta() / update_metadata() without $prev_value
Update only rows matching an old value pass $prev_value
Update one physical row update_metadata_by_mid()
Delete every row matching object + key delete_post_meta() / delete_metadata() without value
Delete one physical row delete_metadata_by_mid()

Do not use a key-level update when preserving multiple distinct rows matters. Do not use $single = true when duplicate keys are meaningful.

Critical contracts

1. Slashing differs by API

add_metadata(), update_metadata(), delete_metadata() and the normal post/user/term/comment wrappers historically expect slashed key/value input and call wp_unslash() internally. If a value has already been unslashed and must retain literal backslashes, slash it for that boundary:

$value = wp_unslash( $_POST['json'] ?? '' );
// Validate the domain value here.
update_post_meta( $post_id, $key, wp_slash( $value ) );

Do not blindly double-slash a superglobal value that has not been normalized. Trace the value's state.

update_metadata_by_mid() does not call wp_unslash() on the new value or key. Pass the already-normalized domain value directly:

update_metadata_by_mid( 'post', $meta_id, $normalized_value );

Flag code that applies the wrapper contract to the by-mid API or vice versa.

2. Post wrappers redirect revisions

add_post_meta(), update_post_meta(), and delete_post_meta() call wp_is_post_revision() and operate on the parent post. If the intent is to change metadata physically stored on a revision, use the generic API with the exact revision ID:

update_metadata( 'post', $revision_id, wp_slash( $key ), wp_slash( $value ) );

Read paths do not perform the same parent redirect. Audit read/write symmetry.

3. Multi-row cardinality

Without $prev_value, update_metadata() updates all rows sharing object ID and key. delete_metadata() without a value deletes all of them. A delete-then-add sequence collapses multiple rows to one and is not atomic.

When transforming each row independently, select stable meta_id values and use by-mid operations. Detect key-renaming collisions before writing. PHP array keys such as "1" can coerce to integer 1 and collide even when source strings look different.

4. Return values are not a simple success boolean

update_metadata() can return a meta ID when it inserts, true when it updates, and false for failure or an unchanged single value. By-mid update returns false when the row is missing, blocked, unchanged at SQL level, or failed.

Do not increment processed counters unconditionally. On ambiguous false, inspect the exact function contract and verify the final value/row existence. For direct SQL also inspect $wpdb->last_error; never expose it to untrusted clients.

5. Scalar types and missing values

Normal meta reads return non-serialized scalars as strings: false becomes '', true becomes '1', and numbers become strings. Arrays and objects retain type. Use metadata_exists() when '' can mean either missing or an explicitly empty value.

get_post_meta( $id ) without a key returns the cache-shaped map and its stored values are not passed through the per-key maybe_unserialize() branch. Do not assume it has the same shape as repeated keyed reads.

6. Registration and authorization

Use register_post_meta() / register_meta() when exposing or validating a known schema. Verify type, single, default, sanitize_callback, auth_callback, object_subtype, and show_in_rest. Registration does not replace endpoint/object capability checks.

Do not sanitize an opaque migration value with sanitize_text_field() merely to satisfy a generic checklist. Preserve exact values when the feature requires it, but validate type, size, encoding, and allowed operation; use prepared SQL or the meta API; escape only at output.

Serialization safety

WordPress automatically serializes arrays/objects. Do not call serialize() or maybe_serialize() before a normal meta write unless preserving a deliberate legacy storage layer. A string that already looks serialized is double-serialized by maybe_serialize() for backward compatibility.

Audit transformations by layer

Never run a blind str_replace() over serialized text. Serialized strings contain byte lengths:

s:3:"foo";

Changing foo to a longer value without decode/re-encode corrupts the payload. This also occurs after one decode of double-serialized data, or inside a nested string that itself contains serialized data. Record the original layer count, decode only the intended trusted layers, transform the domain value, then re-encode the same storage contract.

Do not use is_serialized() as a full integrity check. It recognizes the serialized shape and can accept a string whose embedded byte lengths make an actual unserialize() fail. Validate by safely decoding the expected type in a bounded test path; distinguish a decode failure from the legitimate serialized boolean b:0;.

Before rewriting array keys, preflight transformed keys with type-aware collision detection. Do not partially save a row after a collision.

Treat object construction and recursion as a trust boundary

maybe_unserialize() eventually calls PHP unserialize() with classes allowed. __wakeup() / __unserialize() runs before later code can decide to skip the object. Rate severity by who can write the raw stored payload; a database read alone is not proof of an exploitable object-injection path.

For untrusted serialized input, prefer rejecting it or using JSON. If legacy data must be inspected, use allowed_classes => false, a supported max_depth, byte/node/depth limits, and cycle-aware traversal. allowed_classes => false blocks class instantiation but does not make arbitrary graphs safe to recurse or rewrite.

Cache, hooks, and direct SQL

Metadata APIs run sanitize filters, pre/post hooks, and clear the corresponding {$meta_type}_meta object cache. Direct SQL bypasses these contracts. Use direct SQL only when set-based performance is necessary and the product explicitly decides how to reproduce cache invalidation and hook semantics.

For read loops, prefer priming with update_meta_cache() or a query API that primes meta caches instead of calling keyed getters across unprimed objects.

False-positive guards

  • Do not flag every maybe_unserialize( get_option(...) ) as object injection; trace who can write the raw serialized payload and which gadget classes exist.
  • Do not demand text sanitization for an exact-preservation migration. Demand a defined type/size/encoding contract and safe sinks instead.
  • Do not flag generic metadata APIs merely because wrappers exist; exact revision or meta-row identity can require the generic/by-mid surface.
  • Do not interpret false as definite failure without checking unchanged-state semantics.

Report format

Report the API used, intended row identity/cardinality, slash state, stored and returned type, serialization layers, hooks/caches affected, observed failure, and the corrected contract. Mark speculative object-injection chains as conditional and state the required write primitive.

Cross-references

  • Use wp-batch-mutation-audit for multi-request or concurrent meta changes.
  • Use wp-plugin-options-storage when deciding whether meta is the right storage primitive.

What this skill does NOT cover

  • General nonce, capability, REST, and SQL-injection review.
  • Custom-table schema design or query-plan optimization.
  • A universal parser for hostile PHP serialization.

References

Files (wp-agent-skills)
  • agents
    • openai.yaml 277 B
      interface:
        display_name: "WordPress Metadata API"
        short_description: "Use and audit WordPress metadata safely"
        default_prompt: "Use $wp-metadata-api to review this WordPress metadata implementation for slashing, revisions, multi-row semantics, and serialization safety."
      
  • SKILL.md 9.4 KB
    ---
    name: wp-metadata-api
    description: Implements and audits WordPress post, user, term, comment, and
      generic metadata code, including slashing contracts, revision redirection,
      multi-row keys, exact meta_id operations, return-value ambiguity, cache and
      hook behavior, registration/auth schemas, scalar typing, and safe handling of
      serialized or double-serialized values. Use when code calls get_*_meta,
      add_*_meta, update_*_meta, delete_*_meta, register_meta,
      update_metadata_by_mid, get_metadata_by_mid, maybe_serialize,
      maybe_unserialize, or queries a *_meta table directly.
    metadata:
      wp-skills-author: "Soczó Kristóf"
      wp-skills-contact: "mailto:lonsdale201@hotmail.com"
      wp-skills-plugin: "wordpress"
      wp-skills-plugin-version-tested: "6.0 - 7.1"
      wp-skills-wp-version-tested: "7.1"
      wp-skills-php-min: "7.4"
      wp-skills-last-updated: "2026-08-20"
    ---
    
    # WordPress Metadata API
    
    Choose the correct metadata surface and preserve its historical contracts. This
    skill covers correctness and data integrity, not whether a meta table is the
    right storage primitive for a new feature.
    
    ## Choose the operation by identity
    
    | Intent | API |
    |---|---|
    | Read one key's first value | `get_post_meta( $id, $key, true )` |
    | Read all rows for one key | `get_post_meta( $id, $key, false )` |
    | Add another row | `add_post_meta()` / `add_metadata()` |
    | Update every row matching object + key | `update_post_meta()` / `update_metadata()` without `$prev_value` |
    | Update only rows matching an old value | pass `$prev_value` |
    | Update one physical row | `update_metadata_by_mid()` |
    | Delete every row matching object + key | `delete_post_meta()` / `delete_metadata()` without value |
    | Delete one physical row | `delete_metadata_by_mid()` |
    
    Do not use a key-level update when preserving multiple distinct rows matters.
    Do not use `$single = true` when duplicate keys are meaningful.
    
    ## Critical contracts
    
    ### 1. Slashing differs by API
    
    `add_metadata()`, `update_metadata()`, `delete_metadata()` and the normal
    post/user/term/comment wrappers historically expect slashed key/value input and
    call `wp_unslash()` internally. If a value has already been unslashed and must
    retain literal backslashes, slash it for that boundary:
    
    ```php
    $value = wp_unslash( $_POST['json'] ?? '' );
    // Validate the domain value here.
    update_post_meta( $post_id, $key, wp_slash( $value ) );
    ```
    
    Do not blindly double-slash a superglobal value that has not been normalized.
    Trace the value's state.
    
    `update_metadata_by_mid()` does **not** call `wp_unslash()` on the new value or
    key. Pass the already-normalized domain value directly:
    
    ```php
    update_metadata_by_mid( 'post', $meta_id, $normalized_value );
    ```
    
    Flag code that applies the wrapper contract to the by-mid API or vice versa.
    
    ### 2. Post wrappers redirect revisions
    
    `add_post_meta()`, `update_post_meta()`, and `delete_post_meta()` call
    `wp_is_post_revision()` and operate on the parent post. If the intent is to
    change metadata physically stored on a revision, use the generic API with the
    exact revision ID:
    
    ```php
    update_metadata( 'post', $revision_id, wp_slash( $key ), wp_slash( $value ) );
    ```
    
    Read paths do not perform the same parent redirect. Audit read/write symmetry.
    
    ### 3. Multi-row cardinality
    
    Without `$prev_value`, `update_metadata()` updates all rows sharing object ID
    and key. `delete_metadata()` without a value deletes all of them. A
    delete-then-add sequence collapses multiple rows to one and is not atomic.
    
    When transforming each row independently, select stable `meta_id` values and
    use by-mid operations. Detect key-renaming collisions before writing. PHP array
    keys such as `"1"` can coerce to integer `1` and collide even when source strings
    look different.
    
    ### 4. Return values are not a simple success boolean
    
    `update_metadata()` can return a meta ID when it inserts, `true` when it updates,
    and `false` for failure **or an unchanged single value**. By-mid update returns
    `false` when the row is missing, blocked, unchanged at SQL level, or failed.
    
    Do not increment processed counters unconditionally. On ambiguous `false`,
    inspect the exact function contract and verify the final value/row existence.
    For direct SQL also inspect `$wpdb->last_error`; never expose it to untrusted
    clients.
    
    ### 5. Scalar types and missing values
    
    Normal meta reads return non-serialized scalars as strings: false becomes `''`,
    true becomes `'1'`, and numbers become strings. Arrays and objects retain type.
    Use `metadata_exists()` when `''` can mean either missing or an explicitly empty
    value.
    
    `get_post_meta( $id )` without a key returns the cache-shaped map and its stored
    values are not passed through the per-key `maybe_unserialize()` branch. Do not
    assume it has the same shape as repeated keyed reads.
    
    ### 6. Registration and authorization
    
    Use `register_post_meta()` / `register_meta()` when exposing or validating a
    known schema. Verify `type`, `single`, `default`, `sanitize_callback`,
    `auth_callback`, `object_subtype`, and `show_in_rest`. Registration does not
    replace endpoint/object capability checks.
    
    Do not sanitize an opaque migration value with `sanitize_text_field()` merely
    to satisfy a generic checklist. Preserve exact values when the feature requires
    it, but validate type, size, encoding, and allowed operation; use prepared SQL
    or the meta API; escape only at output.
    
    ## Serialization safety
    
    WordPress automatically serializes arrays/objects. Do not call `serialize()` or
    `maybe_serialize()` before a normal meta write unless preserving a deliberate
    legacy storage layer. A string that already looks serialized is double-serialized
    by `maybe_serialize()` for backward compatibility.
    
    ### Audit transformations by layer
    
    Never run a blind `str_replace()` over serialized text. Serialized strings
    contain byte lengths:
    
    ```text
    s:3:"foo";
    ```
    
    Changing `foo` to a longer value without decode/re-encode corrupts the payload.
    This also occurs after one decode of double-serialized data, or inside a nested
    string that itself contains serialized data. Record the original layer count,
    decode only the intended trusted layers, transform the domain value, then
    re-encode the same storage contract.
    
    Do not use `is_serialized()` as a full integrity check. It recognizes the
    serialized shape and can accept a string whose embedded byte lengths make an
    actual `unserialize()` fail. Validate by safely decoding the expected type in a
    bounded test path; distinguish a decode failure from the legitimate serialized
    boolean `b:0;`.
    
    Before rewriting array keys, preflight transformed keys with type-aware
    collision detection. Do not partially save a row after a collision.
    
    ### Treat object construction and recursion as a trust boundary
    
    `maybe_unserialize()` eventually calls PHP `unserialize()` with classes allowed.
    `__wakeup()` / `__unserialize()` runs before later code can decide to skip the
    object. Rate severity by who can write the raw stored payload; a database read
    alone is not proof of an exploitable object-injection path.
    
    For untrusted serialized input, prefer rejecting it or using JSON. If legacy
    data must be inspected, use `allowed_classes => false`, a supported `max_depth`,
    byte/node/depth limits, and cycle-aware traversal. `allowed_classes => false`
    blocks class instantiation but does not make arbitrary graphs safe to recurse or
    rewrite.
    
    ## Cache, hooks, and direct SQL
    
    Metadata APIs run sanitize filters, pre/post hooks, and clear the corresponding
    `{$meta_type}_meta` object cache. Direct SQL bypasses these contracts. Use direct
    SQL only when set-based performance is necessary and the product explicitly
    decides how to reproduce cache invalidation and hook semantics.
    
    For read loops, prefer priming with `update_meta_cache()` or a query API that
    primes meta caches instead of calling keyed getters across unprimed objects.
    
    ## False-positive guards
    
    - Do not flag every `maybe_unserialize( get_option(...) )` as object injection;
      trace who can write the raw serialized payload and which gadget classes exist.
    - Do not demand text sanitization for an exact-preservation migration. Demand a
      defined type/size/encoding contract and safe sinks instead.
    - Do not flag generic metadata APIs merely because wrappers exist; exact revision
      or meta-row identity can require the generic/by-mid surface.
    - Do not interpret `false` as definite failure without checking unchanged-state
      semantics.
    
    ## Report format
    
    Report the API used, intended row identity/cardinality, slash state, stored and
    returned type, serialization layers, hooks/caches affected, observed failure,
    and the corrected contract. Mark speculative object-injection chains as
    conditional and state the required write primitive.
    
    ## Cross-references
    
    - Use **`wp-batch-mutation-audit`** for multi-request or concurrent meta changes.
    - Use **`wp-plugin-options-storage`** when deciding whether meta is the right
      storage primitive.
    
    ## What this skill does NOT cover
    
    - General nonce, capability, REST, and SQL-injection review.
    - Custom-table schema design or query-plan optimization.
    - A universal parser for hostile PHP serialization.
    
    ## References
    
    - Core contracts: `wp-includes/meta.php`
    - Post wrapper revision behavior: `wp-includes/post.php`
    - Serialization compatibility: `wp-includes/functions.php`
    - Official documentation: <https://developer.wordpress.org/apis/metadata/>
    - Official documentation: <https://developer.wordpress.org/reference/functions/update_metadata/>
    - Official documentation: <https://developer.wordpress.org/reference/functions/update_metadata_by_mid/>
    - Verified source paths:
      - `wp-includes/revision.php`
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related