Building the Petstablished sync plugin: WordPress 6.9 Abilities, Block Bindings, and a single source of truth


Recently I wrote about a small companion plugin that lets any WordPress site embed adoptable pets from the Venango County Humane Society. That plugin is the spreader — it goes on partner sites and pulls pet data over the network. This post is about the other side: the plugin that lives on vcpahumane.org itself, syncs with the shelter’s adoption software, and powers everything the partner plugin reads.

It’s called vcpahumane-petstablished-sync, and it’s the more
architecturally interesting of the two. It’s where most of the
new WordPress 6.9 surface area shows up: the Abilities API,
Block Bindings, the Interactivity API, and several patterns
that I think are worth writing down because they’re not yet well-documented in real-world plugins.

The repo is at github.com/jwincek/vcpahumane-petstablished-sync. If any of the patterns below are useful to you, the code is GPL and the issues tracker is open.

What it does, briefly

Petstablished is a SaaS platform that shelters use to manage their adoptable animals — intake forms, photos, descriptions, status changes, the works. The shelter’s staff updates pets there, not in WordPress. This plugin’s job is to keep WordPress in sync with what’s in Petstablished, then expose that data to the rest of the site (and to the companion plugin running on partner sites) in a way that feels native to WordPress.

That means:

  • A pet custom post type, plus nine taxonomies (pet_animal, pet_breed, pet_age, pet_sex, pet_size, pet_color, pet_coat, pet_attribute, pet_status)
  • A daily sync job that pulls from the Petstablished API, deduplicates changes, and writes pets as posts
  • ~18 Gutenberg blocks for displaying pets — single profiles, grids, filters, comparisons, favorites
  • A faceted browser at /adopt/pets/ with live filter counts, animated transitions, and persistent state across page loads
  • Anonymous-friendly favorites and comparison features that work without forcing visitors to create accounts

The plugin is intentionally large in scope — about 4,000 lines of PHP and maybe half that in JavaScript — but every piece is in service of one of those goals.

Decision 1: configuration as JSON, not PHP

The first decision worth talking about is how the plugin registers things. Most WordPress plugins register custom post types with a giant register_post_type() call somewhere in init. Taxonomies the same way. Meta fields too, scattered across whichever file felt convenient at the time. This works fine for a small plugin and becomes unmaintainable as soon as you have more than a handful.

vcpahumane-petstablished-sync moves all of that into JSON. There’s a config/ directory with several files:

config/

├── post-types.json # CPT definitions

├── taxonomies.json # Taxonomy definitions

├── entities.json # Post meta field definitions

├── abilities.json # Abilities API definitions (more on this below)

└── schemas/ # Reusable JSON Schema fragments

A Petstablished\Core\Config class loads, caches, and hands them out:

```php
$post_types = Config::get_item( 'post-types', 'post_types', [] );

foreach ( $post_types as $slug => $config ) {
    register_post_type( $slug, [
        'labels'       => self::build_labels( $config['labels'] ),
        'public'       => $config['public'] ?? false,
        'show_in_rest' => $config['show_in_rest'] ?? true,
        'rewrite'      => $config['rewrite'] ?? false,
        'supports'     => $config['supports'] ?? [ 'title', 'editor' ],
    ] );
}

Same pattern for taxonomies and meta. Adding a new field is a JSON edit; no PHP changes required for the registration itself.

The Config class also resolves $ref references inspired by JSON Schema, so common fragments (like a pagination input shape used by every list ability) live in one file and get composed into others:

{
  "petstablished/list-pets": {
    "input_schema": {
      "$ref": "schemas/taxonomy-filters.json",
      "properties": {
        "status": { "type": "string" }
      }
    }
  }
}

The benefit isn’t just “less code.” It’s that the shape of the data is in one place and not scattered across files. When I want to know what fields a pet has, what their types are, and which are exposed via REST, I open entities.json. When I want to know what taxonomies exist and what default terms they have, I open taxonomies.json. Nothing is hiding in init callbacks.

There’s a real cost — you can’t grep for register_post_type and find the CPT definition; you have to know to look in JSON. But once you know the convention, the cost is small and the benefit compounds with every new field.

Decision 2: WordPress 6.9 Abilities as the single source of truth

This is the part of the plugin I’m most excited about, and the part I think other developers will get the most value from reading about.

WordPress 6.9 introduced the Abilities API, which is essentially a formal way to register named, schema-validated server actions. An ability has a name (petstablished/get-pet), input and output JSON schemas, a permission callback, and an execute callback. Once registered, an ability can be invoked from PHP, called over REST, used as a Block Binding source, and (with some plumbing) called from the Interactivity API on the front end. The same ability serves all four contexts.

This plugin defines eleven abilities in config/abilities.json:

AbilityPurpose
petstablished/get-petHydrate a single pet by ID
petstablished/list-petsPaginated, filterable list
petstablished/filter-petsFiltered list with live facet counts
petstablished/toggle-favoriteAdd/remove a favorite
petstablished/get-favoritesRead current favorites
petstablished/clear-favoritesClear all favorites
petstablished/update-comparisonAdd/remove from compare list
petstablished/get-comparisonRead compare list
petstablished/get-adoption-statsAggregate counts (available, adopted, pending)

A Provider::register() method reads the JSON, loads the implementation files (includes/abilities/{favorites,comparison,pets,stats}.php), and registers each ability against the core API. Implementations are plain functions in well-named namespaces, so the ability petstablished/toggle-favorite maps to Petstablished\Abilities\Favorites\toggle() by convention. No class boilerplate, no singletons — just functions doing one job each.

Why this matters in practice: the Pet Card block needs to display a pet’s name, image, description, taxonomies, and adoption status. Without abilities, you’d write that logic three times — once for the REST endpoint that powers the editor preview, once for the block render callback, and once for the front-end JavaScript that handles “more like this” or favorite toggles. Each version drifts. Each has its own bugs. Each has to be updated when the data shape changes.

With abilities, the block binding callback is just:

public function get_binding_value( array $args, WP_Block $block ): ?string {
    $post_id = $block->context['postId'] ?? get_the_ID();
    $key     = $args['key'] ?? '';

    $ability = wp_get_ability( 'petstablished/get-pet' );
    if ( $ability ) {
        $pet = $ability->execute( [ 'id' => (int) $post_id ] );
        return $pet[ $key ] ?? null;
    }
}

The REST route is the same call. The Interactivity API store on the front end calls the same ability via a thin wrapper (more on that next). There is one get-pet implementation in the plugin, and every other piece of code asks it for data.

When I add a new field to the pet data shape, I add it to one place (the get-pet ability), and the Pet Card block, the listing grid, the REST endpoint, the editor preview, and the partner plugin’s data all see it at once. This is what “single source of truth” actually feels like in practice — and it’s only really possible because of the Abilities API.

Decision 3: the auth gate workaround

There is one significant problem with using the Abilities API as your front-end data layer: the core /wp-abilities/v1/ REST endpoint requires an authenticated user for everything. No exceptions, no public abilities, no anonymous access.

This is the right default for a general-purpose API — “everything authenticated unless you opt out” is safer than the reverse. But for this plugin it’s a non-starter. Anonymous shelter visitors need to be able to favorite pets, build comparison lists, and apply filters without creating an account. That’s the whole point of those features.

The workaround is small but worth knowing about. The plugin registers its own thin REST routes at /petstablished/v1/{namespace}/{ability}/run that delegate to the abilities while bypassing the core controller’s auth gate:

private const CLIENT_ABILITIES = [
    'petstablished/toggle-favorite',
    'petstablished/get-favorites',
    'petstablished/clear-favorites',
    'petstablished/update-comparison',
    'petstablished/get-comparison',
];

public static function handle_execute( \WP_REST_Request $request ) {
    $ability = self::resolve_ability( $request );
    if ( is_wp_error( $ability ) ) {
        return $ability;
    }

    $input  = self::get_input( $request );
    $result = $ability->execute( $input );  // Ability's own permission_callback runs here
    return rest_ensure_response( $result );
}

The plugin route’s permission_callback is __return_true, so anonymous users can call it. But the ability’s own check_permissions() still runs inside execute() — so the security model isn’t actually bypassed, it’s just relocated. An ability declared with permission: 'public_with_session' in abilities.json remains the source of truth for what anonymous users can do.

The result: the front-end Interactivity store calls /petstablished/v1/petstablished/toggle-favorite/run instead of /wp-abilities/v1/petstablished/toggle-favorite/run. Same ability, same permission checks, no auth wall. Five abilities are exposed this way — the read/write favorites and comparison ones. The list/get/filter abilities don’t need it because they go through Block Bindings or internal PHP calls.

This is the pragmatic reality of working with new WordPress APIs: the core defaults are usually right for the common case, and you sometimes need a small purpose-built escape hatch for the uncommon one. I hope the core API gains an opt-in for public abilities in a future release; until then, this pattern works.

Decision 4: hydration with explicit N+1 prevention

The pet listing grid shows up to 30 pets at a time with name, primary image, key taxonomies (animal, breed, age, sex, size), and a few attribute badges. A naive implementation would call get_post_meta(), wp_get_object_terms(), and get_the_post_thumbnail_url() per pet, which means hundreds of database queries to render one page.

The plugin solves this with a Pet_Hydrator class that exposes two methods: hydrate( $post, $profile ) for one pet, and hydrate_many( $posts, $profile ) for a batch. The batch version primes the relevant caches before hydrating:

public static function hydrate_many( array $posts, string $profile = 'full' ): array {
    $ids = wp_list_pluck( $posts, 'ID' );

    // Prime all meta and term caches at once.
    update_postmeta_cache( $ids );
    update_object_term_cache( $ids, 'pet' );

    // Now hydrate each post — every meta_get and term_get hits the cache.
    return array_map( fn( $post ) => self::hydrate( $post, $profile ), $posts );
}

This isn’t a clever trick — update_postmeta_cache() and update_object_term_cache() are documented core functions. But almost no plugins actually use them, and the difference is huge. A 30-pet listing goes from ~150 queries to ~5. The hydrator also accepts a $profile parameter (full, summary, grid) so listing pages don’t load fields they won’t render.

Every place in the plugin that needs pet data goes through Pet_Hydrator. Block Bindings, REST responses, the abilities — they all share one hydration path. So the N+1 prevention applies everywhere without anyone having to remember to call it.

Decision 5: the Petstablished sync job

The actual sync logic lives in includes/class-petstablished-sync.php. It’s the oldest part of the plugin and the most operationally load-bearing — if it breaks, the whole site goes stale.

A few things about it that I think are worth highlighting:

Pagination is automatic. Petstablished’s API returns up to 100 pets per page; the shelter has between 30 and 60 pets at a time, so one page is usually enough. But the loop continues until current_page >= total_pages or until a safety valve (MAX_PAGES = 50) trips. The safety valve has never fired but it exists because runaway-loop bugs in sync code are catastrophic — they hammer external APIs and rack up charges.

Change detection via SHA-256 hash. Each pet’s API payload is normalized (sorted keys, admin-only fields stripped) and hashed. The hash is stored in post meta as _pet_api_hash. On the next sync, if the new hash matches the stored hash, the post is left untouched — no wp_update_post(), no update_post_meta() calls, no taxonomy re-syncs. For a typical sync where most pets haven’t changed, this turns “update everything” into “update only what’s different.”

private function compute_api_hash( array $data ): string {
    $filtered = array_filter(
        $data,
        fn( $key ) => ! str_starts_with( $key, 'link_' ),
        ARRAY_FILTER_USE_KEY
    );
    $normalized = $this->ksort_recursive( $filtered );
    return hash( 'sha256', wp_json_encode( $normalized, JSON_UNESCAPED_SLASHES ) );
}

The “strip admin-only fields” step matters more than it looks. The Petstablished API returns several fields that change every request (things like signed download URLs with expirations), and including those in the hash would defeat the whole optimization. Stripping them is what makes the dedup actually work.

Batched AJAX from the admin UI. Manual syncs are triggered by an admin button that kicks off a JavaScript-driven batch flow: ajax_start_syncajax_process_batch (called repeatedly) → ajax_finish_sync. Each batch processes a chunk of pets, returns progress, and the JS continues until done. This avoids PHP timeout issues for shelters with hundreds of animals — though VCHS doesn’t have that many, the plugin is built to handle it.

Decision 6: Block Bindings everywhere

The single-pet template (templates/single-pet.html) is a block template — pure block markup, no PHP. Every dynamic field on the page is a Block Binding pulling from petstablished/pet-data:

<!-- wp:heading {
    "level": 1,
    "metadata": {
        "bindings": {
            "content": {
                "source": "petstablished/pet-data",
                "args": { "key": "name" }
            }
        }
    }
} -->
<h1 class="wp-block-heading">Pet Name</h1>
<!-- /wp:heading -->

The <h1>Pet Name</h1> placeholder is what shows in the editor; at render time the binding callback runs, calls wp_get_ability( 'petstablished/get-pet' ), and replaces the content with the actual pet’s name. Same pattern for the description, the adoption status, the breed, the age, the size — each is a binding, each routes through the same ability.

What I like about this is that the template is a designer-editable artifact. A non-developer can open the template in the block editor, rearrange the layout, change the heading levels, add or remove sections, and the bindings keep working. Nothing breaks because the data layer isn’t coupled to the template structure.

The downside is that Block Bindings still feel slightly experimental in WordPress 6.9 — the editor UX for picking a binding source is clunky, and there’s no way to preview real data inside the editor for templates (you only see the placeholder text). I expect this to improve in future releases. For now, it’s a tradeoff: cleaner separation of concerns at the cost of a slightly worse editor experience.

Decision 7: Interactivity API for client-side state

Favorites and the comparison list need to be reactive — clicking a heart icon should update the count immediately, persist across page loads, and survive a full-site navigation without losing state. This is exactly what the Interactivity API is for.

The plugin maintains a global store at assets/js/store.js:

const { state, actions } = store( 'petstablished', {
    state: {
        favorites: getInitialFavorites(),
        get favoritesCount() {
            return state.favorites.length;
        },
    },
    actions: {
        *toggleFavorite() {
            const { petId } = getPetIdFromContext();
            // Optimistic update
            const wasIn = state.favorites.includes( petId );
            state.favorites = wasIn
                ? state.favorites.filter( id => id !== petId )
                : [ ...state.favorites, petId ];

            try {
                yield executeAbility( 'petstablished/toggle-favorite', { id: petId } );
            } catch {
                // Roll back on failure
                state.favorites = wasIn
                    ? [ ...state.favorites, petId ]
                    : state.favorites.filter( id => id !== petId );
            }
            storage.set( 'favorites', state.favorites );
        },
    },
} );

The store is optimistic: it updates local state immediately so the UI feels instant, then talks to the server in the background, then rolls back if the server call fails. State is also mirrored to localStorage so anonymous visitors keep their favorites across sessions.

A few of the blocks (favorites toggle, favorites modal, comparison bar, filters) bind directly to this store via data-wp-on--click and data-wp-text directives. The store is shared, so toggling a favorite on the listing grid immediately updates the favorites count in the header and the heart icon on every other card showing the same pet.

The Interactivity API is the part of WordPress that feels most like a “real” client-side framework, and using it for a feature like favorites — where the data is small, the interactions are simple, and persistence matters — is a much better fit than reaching for React or Vue.

What I’d do differently

The plugin works and I’m proud of it, but if I were starting over there are a few things I’d reconsider:

  1. The dual autoloader was a mistake in retrospect. I started with legacy Petstablished_Foo classes, then added namespaced Petstablished\Core\Config ones, then never finished migrating the old ones. The autoloader supports both, which is convenient but means there are two conventions in the codebase forever. If I were starting over, I’d commit to namespaces from day one.
  2. abilities.json is doing too much. It defines the ability name, the input/output schemas, the permission level, and the callback resolution convention. The schemas in particular are getting unwieldy. I’d consider splitting schemas into their own files and referencing them.
  3. No tests. Same admission as the companion plugin. The sync logic in particular would benefit from a test harness — every edge case I’ve debugged would be a test, and I’d find new ones faster.
  4. The admin UI for triggering syncs is bare-bones. It works, but it’s a single button on a settings page with no history, no diff, no “what would change if I synced now” preview. A proper sync dashboard would be a nice future addition.
  5. No webhook from Petstablished. The sync runs once a day. If a pet’s status changes from “Available” to “Adopted” at 10am, it won’t reflect on the site until the next morning. Petstablished does support webhooks; wiring them up is on the list.

If any of these sound interesting and you want to take a swing, the repo is open and PRs are welcome.

Why this matters beyond one shelter

I want to close on something slightly bigger than the plugin itself.

A small no-kill shelter in northwestern Pennsylvania has, as of this writing, a faceted pet browser with live filter counts, a Petstablished sync with change detection, anonymous-friendly favorites and comparisons, server-side hydration with N+1 prevention, and a companion plugin that lets any other site embed adoptable pets. That’s a feature set comparable to what I’ve seen at much better-funded organizations.

The reason it was tractable to build is that WordPress has spent the last few releases adding the right primitives. Block templates moved the page layout out of PHP. Block Bindings moved the data layer out of templates. The Abilities API moved the action layer out of REST controllers. The Interactivity API moved client-side state out of custom JavaScript. Each of those, on its own, is a small change. All of them together make it possible for one developer working part-time to ship something that feels like a real product.

I think this is the most underrated thing about WordPress in 2026: the gap between “small organization with no tech budget” and “organization that has the kind of website they need” has gotten much smaller, very recently, mostly without anyone saying so. If you work for or with a nonprofit, this is a good time to look at WordPress again.

And if you work on WordPress core — the people building the Abilities API, the Interactivity API, Block Bindings, all of it — thank you. This plugin would not exist in this form without your work, and it is making a real difference for real animals at a real shelter that few people outside of Venango County had heard of before this post. I appreciate it.


The repo is at github.com/jwincek/vcpahumane-petstablished-sync. The companion plugin (the one that lets any WordPress site embed adoptable pets from VCHS) is at github.com/jwincek/vcpahumane-pet-companion. Both are GPL.

If you’d like to help, the issues trackers are open and contributions of any size are welcome — even typo fixes and documentation improvements. And if you use any of these patterns in your own plugins, I’d love to hear about it.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *