Skip to content

Expose post meta in format_post() response (or via filter hook) for custom CPTs #16

Description

@Lonsdale201

Problem

PostManager::format_post() only returns standard WordPress fields (id, title, content, status, taxonomies, featured image, comments, etc.), but never exposes custom post meta. For custom post types
whose primary data lives in post meta — which is the WordPress norm for any non-trivial CPT — the list-posts and get-post abilities effectively return only the title and an opaque ID.

This means an external site-manager / MCP client cannot retrieve the actual content of these CPTs, even with full WP-level access. The post is "visible" but its data is invisible.

Real-world use case

We use a custom formsubmission CPT to capture form submissions across the site (contact form, instructor inquiry, registration forms, training submission, online quizzes, etc.). Each submission stores:

  • top-level meta: _form_id, _form_label, _submitter_email, _referrer_url
  • JSON blob meta: _submission_fields (the user-filled inputs), _submission_context (form-specific extras), _submission_actions (timeline of follow-up actions like emails sent, certificates issued)

Calling site-manager/list-posts with post_type: formsubmission returns nicely titled rows ("Contact submission #1234"), but the client has no way to see who submitted what, when, or what happened next. The meta_key + meta_value filter helps narrow down the query, but it can't expose the values themselves.

This pattern is not unique to us — it applies to virtually every CPT-heavy site (WooCommerce orders, ACF-powered post types, Gravity Forms entries stored as posts, custom directory plugins, etc.).

Proposed solutions

Either of these would fully solve it; both together would be ideal:

1. Add a meta field to the detailed response

In format_post( $post, true ), add the post meta — either the full get_post_meta( $post->ID ) dump, or filtered through an allowlist that excludes WordPress internals (_edit_lock, _edit_last, _thumbnail_id,
etc.):

if ( $detailed ) {
    // ...existing fields...

    $raw_meta = get_post_meta( $post->ID );
    $skip = [ '_edit_lock', '_edit_last', '_thumbnail_id' ];
    $meta = [];
    foreach ( $raw_meta as $key => $values ) {
        if ( in_array( $key, $skip, true ) ) {
            continue;
        }
        // Single-value meta gets unwrapped, JSON strings auto-decode for convenience
        $value = count( $values ) === 1 ? maybe_unserialize( $values[0] ) : array_map( 'maybe_unserialize', $values );
        $meta[ $key ] = $value;
    }
    $data['meta'] = $meta;
}

This is backwards-compatible (additive only) and unlocks every CPT use case in one shot.

2. Add a filter hook

Even simpler and more flexible — let consumers decide what to expose:

private static function format_post( \WP_Post $post, bool $detailed = false ): array {
    $data = [ /* ...existing... */ ];
    // ...
    return apply_filters( 'lw_site_manager/post_data', $data, $post, $detailed );
}

Sites can then opt-in per post type:

add_filter( 'lw_site_manager/post_data', function ( $data, $post, $detailed ) {
    if ( $post->post_type === 'formsubmission' && $detailed ) {
        $data['meta'] = [
            'form_id'         => get_post_meta( $post->ID, '_form_id', true ),
            'form_label'      => get_post_meta( $post->ID, '_form_label', true ),
            'submitter_email' => get_post_meta( $post->ID, '_submitter_email', true ),
            'fields'          => json_decode( get_post_meta( $post->ID, '_submission_fields', true ), true ),
            // ...
        ];
    }
    return $data;
}, 10, 3 );

The filter approach is also great for redaction — sensitive sites can strip values before they leave the server.

3. (Optional) Dedicated ability site-manager/get-post-meta

For when a client wants only the meta payload without the full post envelope. Lower priority than 1 or 2.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions