D
P
0

WordPress & JavaScript

Owner Listings Always Publish With Zero Photos? Upload Only Builds a Preview, the Publish Handler Skips `gallery`

July 24, 2026·5 min read
Owner Listings Always Publish With Zero Photos? Upload Only Builds a Preview, the Publish Handler Skips `gallery`

I was building an owner dashboard for a client site: a property-booking platform where owners add their own listings, photos included. The flow was standard. An owner opens the "Add Property" form, fills in title, price, description, then drags a few photos into the upload area. Thumbnails appear, validation passes, they click Publish, and the listing goes live.

The problem only showed up during pre-launch QA: every listing an owner created published with zero photos. The property page rendered the theme's placeholder images instead of the photos that were just uploaded. I created a test listing myself with a valid 1600×1000 photo, watched the preview render cleanly in the form, saw validation go green, and published. I opened the public page: placeholders again. I inspected the post data and found gallery set to an empty array and featured_image null. The photo I had plainly seen previewed in the form was gone.

Tracing it

My first reflex was that the server was rejecting the images. Wrong mime type, maybe, or a file too big. I opened the server-side create_item handler. It expects a gallery field: an array of already-uploaded attachment IDs. That made sense; a property stores its gallery as a list of WordPress media IDs. But if the server were rejecting uploads, there should be a trace. The error_log was clean. No new attachments had been created in the Media Library at all.

So I went to the place I should have checked first: the browser Network tab. I submitted the form again while recording. The POST that creates the property fired, its body carrying title, price, description, and a gallery set to an empty array. There was no upload request before it. No file ever left the browser. The photos were never sent anywhere.

At that point the puzzle flipped. This was not the server rejecting images. It was images that never reached the server.

Root cause

I opened dashboard.js and traced what happens when an owner picks a photo. The handleFiles() function did exactly one thing: build a preview.

// dashboard.js: preview only, no upload
function handleFiles(files) {
  Array.from(files).forEach((file) => {
    const reader = new FileReader();
    reader.onload = (e) => {
      const thumb = renderThumb(e.target.result);
      thumb._file = file; // the File just clings to the DOM, never sent
    };
    reader.readAsDataURL(file);
  });
}

FileReader reads the local file and shows it as a data URL. The File object gets stashed on the thumbnail element's _file property, living in the DOM. That is all. No fetch, no upload. The preview I saw in the form was pure client-side theater. Pretty, but it never touched the server.

Then I read the submit handler, initPropertyForm(). Here was the line that made me exhale:

const body = {
  title: form.title.value,
  price: form.price.value,
  // Photos handled via separate upload flow (deferred)
};

Photos were deliberately skipped while assembling the body, with a comment saying uploads were handled by a "separate flow" that had been deferred. The catch: that separate flow was never built. Someone marked it as a TODO, the form kept working and looked functional because the preview was convincing, and the part that actually uploads the photos was never written. The server wanted gallery to hold an array of attachment IDs, got an empty array, and every listing published without a single photo. Syntactically perfect, empty in substance.

The fix

What was missing was a whole step: the files have to become WordPress attachments first, then their IDs travel with the property.

The easy option would be to grant the property_owner role the upload_files cap and use the built-in wp/v2/media endpoint. I rejected that. upload_files is too broad: it opens the entire Media Library to the owner, including media owned by other owners and admins. On a multi-owner site, that is a privacy leak I did not want.

Instead I built a narrow, purpose-scoped REST endpoint just for property photos:

register_rest_route( 'listings/v1', '/properties/photo', array(
    'methods'             => 'POST',
    'callback'            => 'upload_property_photo',
    'permission_callback' => function () {
        return current_user_can( 'manage_properties' );
    },
) );
 
function upload_property_photo( WP_REST_Request $request ) {
    $file = $request->get_file_params()['file'] ?? null;
    // validate: mime jpeg/png/webp, size <= 10MB, check dimensions
    require_once ABSPATH . 'wp-admin/includes/file.php';
    require_once ABSPATH . 'wp-admin/includes/image.php';
 
    $moved = wp_handle_upload( $file, array( 'test_form' => false ) );
    $attachment_id = wp_insert_attachment( array(
        'post_mime_type' => $moved['type'],
        'post_author'    => get_current_user_id(),
        'post_status'    => 'inherit',
    ), $moved['file'] );
 
    $meta = wp_generate_attachment_metadata( $attachment_id, $moved['file'] );
    wp_update_attachment_metadata( $attachment_id, $meta );
 
    return array( 'id' => $attachment_id, 'url' => $moved['url'] );
}

The endpoint is guarded by the manage_properties cap, so only owners reach it. It validates mime and size, moves the file with wp_handle_upload, creates an attachment whose post_author is the owner, builds metadata, and returns { id, url }.

On the client, I wrote the step that had been missing all along: upload every photo first, collect the IDs, then send the property.

// dashboard.js: upload first, collect IDs, then send the property
async function collectGalleryIds() {
  const thumbs = document.querySelectorAll('.photo-upload__thumb');
  const ids = [];
  for (const thumb of thumbs) {
    if (thumb.dataset.id) {            // edit mode: keep existing IDs
      ids.push(Number(thumb.dataset.id));
      continue;
    }
    const fd = new FormData();
    fd.append('file', thumb._file);
    const res = await fetch('/wp-json/listings/v1/properties/photo', {
      method: 'POST',
      headers: { 'X-WP-Nonce': window.listingNonce },
      body: fd,
    });
    ids.push((await res.json()).id);
  }
  return ids;
}

Then at submit: body.gallery = await collectGalleryIds();. Photos without an ID get uploaded and produce new IDs; in edit mode, thumbnails that already carry a data-id are preserved so existing photos survive, and new ones are appended. Now the server receives a gallery with real IDs, and listings publish with their galleries intact.

Takeaways

  • A preview is not an upload. FileReader only shows a file in the browser; it proves nothing about server state. Do not trust a thumbnail until you see the request actually go out.
  • Be suspicious of comments like "deferred", "TODO", or "handled elsewhere". Unfinished stubs are quietly dangerous because the surrounding UI still looks healthy.
  • Read the real request body in the Network tab instead of trusting a green UI. A gallery that is an empty array points straight at the root cause.
  • For privileged actions, prefer a narrow custom endpoint over granting a broad cap like upload_files. Tight scope closes cross-owner leaks.
  • In edit mode, preserve existing attachment IDs before appending new ones, or an update will silently drop the old photos.