Tag: Interactivity API

  • Building an open-source donations plugin for small nonprofits (without the paywall)


    If you’ve ever helped a small nonprofit pick a WordPress donation plugin, you already know the shape of the problem. There are several reputable options — GiveWP, Charitable, WooCommerce’s own donation extensions — and all of them follow the same business model: a free version that handles basic one-time donations, and a paid tier that unlocks the features the
    nonprofit actually needs. Recurring donations: paid. Custom fields: paid. Campaign progress bars: paid. Donor management: paid. Tax receipts: paid. The paid tiers usually start around $200 a year and climb from there.

    For a well-funded nonprofit, $200 a year is a rounding error. For a
    small no-kill shelter that runs on community donations / memberships and operates on a tight budget, it’s a real expense — and worse, it’s an expense that scales the wrong way: the more your nonprofit grows, the more features you need, the more you pay. You end up paying a software vendor for permission to accept your own donations.

    This post is about a plugin I built to refuse that bargain. It’s called
    vcpahumane-wc-donations (still named starter-shelter internally — more on that in a moment), and it’s a WordPress plugin that turns WooCommerce into a real donations platform without locking the useful features behind a paywall. It powers the donations system at Venango County Humane Society, a small no-kill shelter in northwestern Pennsylvania that I’ve been doing development work for since early 2025.

    The plugin went live on the production site last week. As of this writing it’s deployed, integrated, and ready. Writing this now feels like the right moment: while the design decisions are fresh and before the inevitable post-launch reality has forced me to revise them.

    The repo is at github.com/jwincek/vcpahumane-wc-donations. It’s GPL, the issues tracker is open, and developer collaboration is the primary reason this post exists.

    A note about the name

    The plugin’s GitHub repo and the post title both call it
    vcpahumane-wc-donations, but if you clone the repo and look inside, the main file is starter-shelter.php, the namespace is Starter_Shelter\, and the text domain is starter-shelter. That’s residual from earlier iterations — the plugin started as a “shelter donations starter template” intended to be reusable across different shelter sites, then got specific to VCHS, then I started thinking about it as a generic starter again. A rename is planned but hasn’t happened yet.

    I’m mentioning this up front because it’s exactly the kind of thing
    that will confuse a developer cloning the repo for the first time, and I’d rather acknowledge the inconsistency than pretend it isn’t there. Everything in this post that says “vcpahumane-wc-donations” you can read as “the plugin formerly known as starter-shelter, currently being renamed in place.” If you contribute a PR before the rename is done, you’ll see both names floating around, and that’s expected.

    What this plugin does, briefly

    It accepts donations, memberships, and in-memoriam tributes through WooCommerce’s checkout. It stores those records as custom post types that the shelter can query, report on, and display however they want. It ships with about ten Gutenberg blocks for donation forms, memberships, memorial walls, campaign progress, and donor stats. It has an admin UI for managing everything. It’s all GPL.

    It does not (yet) support recurring donations, donor self-service
    accounts, tax receipt PDFs, or employer matching. Those are real gaps and I’ll talk about them at the end. The plugin is intentionally a starting point, not a finished product.

    What it does have, that I think is architecturally interesting:

    1. A pattern for syncing custom post types with WooCommerce variable products that lets you treat donations as first-class data instead of as a side effect of e-commerce orders
    2. A JSON-driven input mapping system that converts WooCommerce order data into structured donation records without writing any custom processing code per donation type
    3. A memorial wall block that solves a real maintenance problem the shelter was facing
    4. WordPress 6.9 Abilities API integration that gives the plugin the same single-source-of-truth architecture I wrote about for the Petstablished sync plugin

    Decision 1: CPTs over WooCommerce as the source of truth

    The first and biggest decision. Most WordPress donation plugins do one of two things:

    • Build their own checkout, payment gateway integration, and order storage from scratch (this is what GiveWP does)
    • Treat donations as WooCommerce orders and store everything in the WooCommerce order tables (this is what most WC-based donation plugins do)

    The first approach means rebuilding everything WooCommerce already does. The second approach means your donation data lives in tables designed for selling t-shirts, not for tracking philanthropic giving — and querying “how much did this donor give in 2025, and to which programs” requires joining wp_wc_orders to wp_wc_order_itemmeta to figure out what the line items meant.

    This plugin takes a third approach. WooCommerce handles the transaction, custom post types are the source of truth for the donation data. When a customer completes an order containing donation items, the plugin processes the order, extracts the donation-relevant data, and creates new posts in custom post types:

    • sd_donation — one record per donation transaction
    • sd_membership — one record per active membership
    • sd_memorial — one record per memorial tribute
    • sd_donor — one record per unique donor (deduplicated by email)

    The WooCommerce order remains as the transaction log, but it’s not where queries go. When the admin asks “show me all donations from November 2025 grouped by donor,” the query runs against sd_donation posts (which are indexed and meta-cached normally), not against the WooCommerce order tables.

    This has several practical benefits:

    Reporting is fast and natural. A WP_Query against
    post_type => 'sd_donation' with date and donor filters is the kind of query WordPress is designed for. Joining order tables for the same question requires custom SQL.

    Donations have their own permalinks, taxonomies, and meta. A
    donation can be tagged to a campaign, attached to a memorial, marked anonymous, given a public-facing display name distinct from the billing name, and so on — all using WordPress’s native meta and taxonomy systems.

    The plugin can evolve independently of WooCommerce. If WooCommerce changes its order table structure (and it has, recently), the donation data is unaffected. The integration layer is small and the data layer is decoupled.

    Migrations and exports are easy. Exporting “all donations” is a
    post export, not a WooCommerce order export with a filter applied.

    The cost is that the plugin has to maintain the sync from orders to
    posts, which is the next decision.

    Decision 2: a config-driven input mapping DSL

    When a WooCommerce order completes, the plugin needs to know which items are donations, which are memberships, which are memorial tributes — and for each one, how to extract the relevant fields from the order’s line items, custom checkout fields, product variations, and order meta. Hardcoding that logic per product type would mean a PHP function per donation type, and adding a new donation type would require code changes.

    Instead, the entire mapping lives in config/products.json:

    
    ```json
    {
      "shelter-donations": {
        "ability": "shelter-donations/create",
        "input_mapping": {
          "amount": { "source": "item_total" },
          "allocation": {
            "source": "attribute",
            "key": "preferred-allocation",
            "transform": "normalize_allocation",
            "default": "general-fund"
          },
          "dedication": { "source": "order_meta", "key": "_sd_dedication" },
          "is_anonymous": {
            "source": "order_meta",
            "key": "_sd_is_anonymous",
            "transform": "boolean",
            "default": false
          },
          "campaign_id": { "source": "order_meta", "key": "_sd_campaign_id" }
        }
      },
      "shelter-memorials": {
        "ability": "shelter-memorials/create",
        "input_mapping": {
          "honoree_name": { "source": "order_meta", "key": "_sd_honoree_name" },
          "memorial_type": {
            "source": "attribute",
            "key": "in-memoriam-type"
          },
          "tribute_message": {
            "source": "order_meta",
            "key": "_sd_tribute_message"
          },
          "notify_family": {
            "source": "composite",
            "fields": {
              "enabled": { "source": "order_meta", "key": "_sd_notify_family_enabled" },
              "name": { "source": "order_meta", "key": "_sd_notify_family_name" },
              "email": { "source": "order_meta", "key": "_sd_notify_family_email" }
            }
          }
        }
      }
    }

    The Product_Mapper class reads this config and turns it into the input array passed to the corresponding ability. It supports several “source” types — item_total, attribute, order_meta, order_field, item_meta, product_meta, static, and composite (for nested fields like the family notification block) — plus optional transform functions and default values.

    The win here isn’t that the input mapping system is unusual — it’s that adding a new donation type is a JSON edit, not a code change. Want to add a “wishlist item purchase” donation type that maps to a specific allocation? Add an entry to products.json, point it at an existing or new ability, and the order processor handles the rest. The plugin doesn’t need to learn about the new product type at the PHP level.

    I’ll be honest: this is the kind of pattern that’s easy to over-engineer. For a plugin with three product types I could have just written three PHP functions and called them from a switch statement. I went with the config-driven approach because (a) I expect to add more product types over time, and (b) I’d already built the same pattern for the Petstablished sync plugin and was happy with how it scaled. The cost of the abstraction is low; the benefit compounds with each new type.

    Decision 3: the memorial wall

    I want to spend some time on this section because it’s the feature that has the clearest non-technical story, and it’s the second-most important thing the plugin does after actually accepting donations.

    The Venango County Humane Society receives around 300 memorial donations a year. Most of those are “in memory of” tributes — someone loses a beloved pet or family member and makes a donation to the shelter in their memory. The shelter’s previous practice was to maintain a flat list / spreadsheet of memorials on their website, manually, by hand. As you can imagine, this was a real pain to keep up: every memorial meant editing a static page, every typo meant re-editing it, and it scaled badly with volume.

    The shelter would have accepted the same flat list / spreadsheet. What they got instead is the memorial wall block: a paginated, searchable, year-filterable grid of all public memorial tributes, with each card showing the honoree’s name, the tribute message, the donor’s name (unless they chose to be anonymous), and the date. The block uses the WordPress 6.9 Interactivity API for client-side pagination — search and filter without page reloads, with bookmarkable URL state via query parameters.

    <!-- wp:vcpa/memorial-wall {
        "columns": 3,
        "perPage": 12,
        "showSearch": true,
        "showYearFilter": true,
        "paginationStyle": "numbered"
    } /-->
    

    The data flows like this: a memorial donation is placed via the memorial form block in the donation flow; it goes through WooCommerce checkout; the order processor extracts the honoree name, message, and donor info; a new sd_memorial post is created; and the memorial wall block displays it on the next page render. End to end, the only “manual” step is the donor filling out the form. The shelter staff doesn’t touch anything.

    Three things about this section worth knowing:

    The memorial wall grew out of a separate plugin. I originally built in-memoriam-donations-manager as a standalone plugin and later folded its functionality into the donations plugin. The separate plugin still exists in the same parent directory but it’s deprecated. The lesson: when two plugins are doing related work for the same nonprofit, fold them together early. The architectural overhead of maintaining two plugins for one shelter is real and not worth it.

    Anonymous donors are first-class. The form has an “anonymous” checkbox; when it’s checked, the donor name is replaced with “Anonymous” on the wall but the donation itself is still recorded under the donor’s account in the database. The display layer respects the privacy choice; the data layer doesn’t lose information.

    The donor name is denormalized into the memorial post. When the memorial is created, the donor’s display name is copied into a _sd_donor_display_name meta field on the memorial post. This is unusual — the “right” way would be to look up the donor by ID at display time. The reason for the denormalization is search: the memorial wall has a search box, and searching across memorials by donor name without N+1’ing into the donor table requires the donor name to be on the memorial post itself. This is a deliberate performance decision that I’m noting here because it’s the kind of thing that looks wrong on first read.

    Decision 4: Abilities API as the action layer

    Same pattern as the Petstablished sync plugin (which I wrote about in a previous post in this series), so I’ll keep this section short. Every action the plugin can take is registered as a WordPress 6.9 Ability with a name, an input schema, a permission callback, and an execute function:

    • shelter-donations/create — create a new donation record
    • shelter-donations/list — list donations with filters
    • shelter-memorials/create — create a new memorial
    • shelter-memorials/list — list memorials with filters
    • shelter-memberships/create — create a new membership
    • shelter-donors/get — get a donor by email
    • shelter-donors/upsert — create or update a donor
    • shelter-reports/summary — aggregate reporting
    • … and several more

    When a WooCommerce order completes, the order processor doesn’t write to the database directly — it executes abilities. When the memorial wall block fetches data, it executes the shelter-memorials/list ability. When the admin reports page generates a summary, it executes the shelter-reports/summary ability.

    The benefit, as in the Petstablished plugin, is that there’s one implementation of “create a donation” in the entire codebase, and the order processor, the REST API, the import/export tools, and the internal data integrity checks all share it. If I change how donations are stored, the change happens in one place.

    The Abilities API also gives me a clean permission model. Most abilities are declared as internal (only callable from the plugin itself) or admin_only. The few that need front-end access — for the memorial wall block, the donor stats block, the donation form’s campaign list — go through a thin REST wrapper, the same pattern I documented in the Petstablished sync post.

    Decision 5: standard WooCommerce variable products with custom prices

    The plugin doesn’t define a custom WooCommerce product type. Donations, memberships, and memorials are all standard variable products with specific attributes:

    • shelter-donations (product) → variations for “General Fund,” “Medical Care,” “Food & Supplies,” etc., via the preferred-allocation attribute
    • shelter-memberships (product) → variations for tier names (“Single $10,” “Family $25,” “Contributing $50”) via the membership-level attribute
    • shelter-donations-in-memoriam (product) → “Person” or “Pet” variations via the in-memoriam-type attribute

    All of these products are created automatically on plugin activation by an Activator class. They’re marked virtual (no shipping), no inventory tracking, and tax-exempt. The product images are inline SVGs generated at activation time.

    The clever part is that these are variable products with dynamic prices. WooCommerce normally requires variations to have fixed prices, but the donation forms let the donor enter any amount. The plugin uses the woocommerce_before_calculate_totals hook to override the line item price before the cart total is calculated:

    add_action(
        'woocommerce_before_calculate_totals',
        function ( $cart ) {
            foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) {
                if ( isset( $cart_item['sd_custom_amount'] ) ) {
                    $cart_item['data']->set_price( $cart_item['sd_custom_amount'] );
                }
            }
        }
    );
    

    This works, and it’s the standard way to do “name your own price” products in WooCommerce, but it’s the kind of thing that would be nice to formalize. A custom product type might be cleaner long-term; for now, the standard variable product approach has the benefit of working with every WooCommerce extension out of the box.

    What’s still open

    Five features I haven’t built yet, in rough order of how often they get requested by nonprofits:

    1. Recurring donations. This is the single biggest gap. Monthly donors are roughly five to ten times more valuable to a nonprofit over their lifetime than one-time donors, and every commercial donation plugin paywalls recurring giving as their flagship premium feature. WooCommerce Subscriptions is a natural fit and the plugin’s order processor already has a hook for woocommerce_subscription_renewal_payment_complete — the missing work is the membership-renewal-handling logic, the donor-facing UI for managing recurring donations, and the failure handling for declined renewals. This is the feature I most want help building, and the one most likely to make this plugin an actual replacement for paid alternatives.

    2. Donor accounts and a self-serve donor portal. Currently, donors have no way to log in, see their giving history, update their contact info, or change a recurring donation. WooCommerce’s “My Account” page is registered but it’s mostly empty for donor-specific use cases. I’m planning to pick this up next.

    3. Tax receipts. Year-end donor summaries (PDF, optionally emailed) and individual donation receipts are useful for both donors and shelter staff. The shelter currently handles this manually, which is the kind of work that scales badly with donor count. There’s a stub donation-receipt email template in the plugin but no PDF generation behind it.

    4. Employer matching support. Many corporate donors check “is this match-eligible?” before giving. A simple “your employer matches donations” question on the donation form, plus a list of known match-eligible employers that the shelter can maintain, would unlock a meaningful slice of corporate giving without requiring full matching automation.

    5. Business sponsor logo display. The plugin already collects business sponsor logos at checkout (with admin moderation) but doesn’t have the front-end blocks to actually display them anywhere. Picking this up alongside donor accounts.

    If any of those sound interesting and you’d like to take a swing, the repo welcomes issues and pull requests. The codebase has good separation of concerns, the abilities pattern means most new features can be added without touching unrelated code, and I’m happy to pair on design decisions before anyone starts implementing.

    A few smaller things worth knowing

    The plugin requires WordPress 6.9 and WooCommerce. The 6.9 requirement comes from the Abilities API; the WooCommerce requirement is obvious. This is a meaningful constraint — some smaller shelters don’t have 6.9 yet — but it’s the right constraint, because the plugin’s architecture wouldn’t work without abilities.

    There are no automated tests yet. Same admission as the companion plugin and the sync plugin. The donation flow in particular would benefit from end-to-end tests, and the order processor is the right candidate for a unit-testable harness. If you want to contribute a test setup, this is the most valuable kind of contribution you can make.

    Performance is decent but not benchmarked. The plugin uses an Entity Hydrator pattern with N+1 prevention (same approach as the Petstablished sync plugin), but I haven’t actually measured page load times under realistic donation volumes. If you’re using this on a site with thousands of memorials, please open an issue with your numbers — even “no problems noticed” is useful data.

    There’s a legacy data migration tool for shelters coming from other donation plugins. It’s currently scoped to the specific predecessor I wrote (shelter-donations-wc-simple) but the framework is general — if you’re moving from GiveWP, Charitable, or another WC-based plugin, it should be possible to write an importer module.

    Why I built this

    I want to close on something I’ve been thinking about a lot, both for this plugin and for the others in this series of posts.

    Nonprofit software has a particular flavor of business model that’s worth naming explicitly. Most commercial nonprofit tools — donation platforms, donor management CRMs, email marketing systems, event management — operate on the principle that the most important features should cost money, and that nonprofits will pay for them because they have to. This is a perfectly rational business model. It’s also, collectively, a tax on the entire nonprofit sector. Money that could be spent on programs is instead spent on software vendors.

    Open source has historically been bad at addressing this. The free WordPress donation plugins exist, but they’re free because they’re deliberately limited, with the expectation that serious nonprofits will upgrade. The premium tiers are not “for the very largest nonprofits with complex needs” — they’re for anyone who actually wants to run a donation program. The pricing is not designed to reflect cost; it’s designed to capture value from organizations that can’t afford to negotiate.

    A genuinely open-source alternative is uncomfortable for everyone involved. It threatens the commercial vendors’ business model, it asks nonprofits to use software that doesn’t have a 24/7 support hotline, and it puts the burden of maintenance on developers who have to find some other way to fund their time. It is not obviously sustainable. I am building this on a small monthly stipend from a shelter that has fewer than sixty animals at a time, and the math of “how does this scale to other shelters” is genuinely unclear to me.

    But I think it’s worth doing anyway, for three reasons:

    1. The technical foundations have gotten dramatically better. WordPress 6.9’s Abilities API, WooCommerce’s maturity as a payments platform, the Block Editor’s evolution into a real content management surface, the Interactivity API — these are the building blocks that make a serious open-source donation plugin tractable for one developer to build. Five years ago this would have been a much bigger project.
    2. The collective value is much larger than the per-shelter value. A plugin that saves one shelter $200 a year in software fees is not interesting. A plugin that saves a thousand shelters $200 a year each is $200,000 a year of recovered nonprofit budget. The marginal cost of a shelter adopting an existing plugin is nearly zero; the marginal value is real.
    3. Someone has to start. Open-source nonprofit infrastructure doesn’t exist by default. It exists because individual developers decide to build it and then maintain it. I am one such developer building one such piece of infrastructure for one such nonprofit, and I’m publishing it openly in the hope that others will find it useful.

    If you work for or with a nonprofit and you’d consider trying this plugin, I’d love to hear from you. If you’re a developer who’s frustrated by the same paywalled-features dynamic and you want to help, even better.

    The repo is at github.com/jwincek/vcpahumane-wc-donations. Issues, PRs, and emails are welcome.

  • 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.