D
P
0
← All articles Baca dalam Bahasa Indonesia

WordPress & PHP in Production

New Listings Never Reach the Moderation Queue or Search? `_listing_status` Is Never Written on Create

· · 6 min read
New Listings Never Reach the Moderation Queue or Search? `_listing_status` Is Never Written on Create

Some bugs shout. This one was silent, and it was silent in the most effective way possible: every person involved was looking at a screen that seemed perfectly reasonable.

I was working on a property booking platform. Owners submit their own listings through a dashboard, and an admin has to approve each one before it can appear publicly. A standard moderation flow. Owner submits, admin approves, listing enters search.

What actually happened: owner-created listings never went anywhere. Not to the moderation queue, not to search. Ever.

The symptom: three screens that all looked correct

This is why the bug survived so long.

The owner submits the form, gets a success message, and their new property immediately appears under "My Listings". From where they sit, everything worked. They simply assume the admin has not reviewed it yet.

The admin opens the moderation page and it is empty. Not an error, not a blank screen, just a polite empty state saying nothing is waiting for approval. From where they sit, that is good news. The queue is clear.

A visitor searches the public site and the listing is not there. Also perfectly reasonable, since it has not been approved.

Three people, three sensible conclusions, and not one of them saw anything worth reporting as a bug. The report only surfaced during pre-launch QA, when someone finally compared two numbers: the count of submitted listings kept climbing, and the moderation queue was still at zero.

Tracing it: the post exists, it just never counts

The cheapest first step was confirming the data actually saved. I opened wp-admin and the listing was right there, with its title, price, description and photos. So the REST endpoint worked, wp_insert_post() succeeded, the post was real.

Which meant the problem was not writing, it was reading. I opened the query behind the moderation page. It looked roughly like this:

$pending = get_posts( array(
    'post_type'      => 'listing',
    'posts_per_page' => 20,
    'meta_query'     => array(
        array(
            'key'   => '_listing_status',
            'value' => 'pending',
        ),
    ),
) );

The public search query used the same shape with approved instead. Both filter on the same meta key.

So I checked that meta on the new listing. Nothing. Not a wrong value, not an empty string. The _listing_status row for that post had never existed.

Root cause: one line that was never written

I opened the create_item handler in the REST controller. It builds $postarr, calls wp_insert_post(), saves price, capacity, address, gallery, then returns a success response. Every business meta field is written properly.

The only thing it never writes is the moderation status.

// create_item writes everything except the field that decides visibility
update_post_meta( $id, '_listing_price', $price );
update_post_meta( $id, '_listing_capacity', $capacity );
update_post_meta( $id, '_listing_gallery', $gallery_ids );
// no line for _listing_status

So every new listing landed in limbo. It was not pending, not approved, not rejected. It had no status at all.

And this is where WordPress meta_query semantics close the door completely. A meta_query becomes a JOIN against the postmeta table. If the meta row does not exist, there is nothing to compare against, so the post drops out of the result before any value comparison even runs. Missing meta does not equal any value, and the part that trips people up, it also fails negative comparisons like != and NOT IN. To catch those rows you have to ask for them explicitly with NOT EXISTS.

Which means a statusless listing was not just missing from one place. It was missing from every view that filters on that key. The moderation page filters for pending, so it slipped through. Search filters for approved, so it slipped through there too. No view happened to catch it, because every view was built on the same contract.

That contract has two sides: the readers that filter, and the writer that populates. The reader side was complete. The writer side had a hole, and nobody complained, because absent data never throws.

Fix, part one: write the default status at creation

The core fix is that short. As soon as the post is created, give it a starting status:

$id = wp_insert_post( $postarr, true );
 
if ( is_wp_error( $id ) ) {
    return $id;
}
 
update_post_meta( $id, '_listing_status', 'pending' );

What matters here is not the size of the change but the decision behind it: a default state has to be written, not implied. The old code effectively treated "has no status" as meaning "waiting for approval". A human reads it that way. A query does not. To a query, having no status means qualifying for nothing.

Fix, part two: backfill the listings already stuck in limbo

Patching create_item only rescues future listings. Everything submitted over the previous weeks stayed invisible, and those were real properties belonging to real people who believed they were in a queue.

So I added a one-time backfill on admin_init, gated by an option so it cannot run twice:

add_action( 'admin_init', 'maybe_backfill_listing_status' );
 
function maybe_backfill_listing_status() {
    if ( get_option( 'listing_status_backfilled' ) ) {
        return;
    }
 
    $ids = get_posts( array(
        'post_type'      => 'listing',
        'post_status'    => 'publish',
        'posts_per_page' => -1,
        'fields'         => 'ids',
        'meta_query'     => array(
            array(
                'key'     => '_listing_status',
                'compare' => 'NOT EXISTS',
            ),
        ),
    ) );
 
    foreach ( $ids as $id ) {
        update_post_meta( $id, '_listing_status', 'pending' );
    }
 
    update_option( 'listing_status_backfilled', 1 );
}

Three things I was careful about. NOT EXISTS keeps the backfill from touching anything that already has a value. Everything is set to pending rather than approved, because a backfill has no business making moderation decisions on the admin's behalf. And the option gate is not a nicety: without it, that unbounded query runs on every single admin request, forever.

Verifying it

I did not want to trust this until I had watched the whole cycle run. So I loaded wp-admin once to trigger the backfill, then opened the moderation page. An older listing that had been invisible was now sitting there marked Pending. I clicked Approve, watched the status flip to approved, then opened the public search page. The listing appeared.

Only then did I call it done. A backfill that merely "looks like it ran" but is never verified end to end just swaps one limbo for another.

A quick diagnostic if you suspect the same thing

Any time you have queries filtering on meta, it is worth occasionally counting the rows that have no such meta at all. From PHP, use NOT EXISTS as above. If you would rather go straight to the database:

SELECT p.ID, p.post_title
FROM wp_posts p
LEFT JOIN wp_postmeta m
  ON m.post_id = p.ID AND m.meta_key = '_listing_status'
WHERE p.post_type = 'listing'
  AND p.post_status = 'publish'
  AND m.meta_id IS NULL;

If that count is anything other than zero, you have a set of rows that will never surface in any view. And because they never surface, they will never report themselves either.

What I took away