Tag: Block Editor

  • 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 pet companion plugin: server-rendered blocks, no build step


    I’ve been doing development work for the Venango County Humane Society since early 2025. Most of what I’ve built lives behind the scenes — a Petstablished sync plugin, some custom post types, a faceted pet browser. But earlier this month I shipped something different: a small companion plugin that lets any WordPress site embed adoptable pets from the shelter using Gutenberg blocks.

    If you’re a partner site looking to install it, I wrote a separate post for that. This one is for WordPress developers — what’s in the plugin, what the interesting decisions were, and what’s still open. The repo is at github.com/jwincek/vcpahumane-pet-companion.

    The shape

    Two blocks, no build step, no JavaScript bundler, no node_modules. PHP renders everything; JavaScript exists only in the editor (edit.js) for the sidebar controls. Both blocks talk to the source site’s standard /wp-json/wp/v2/pet endpoint with a transient cache layer in front.

    The plugin is intentionally small — a few hundred lines of PHP, a few hundred of plain JavaScript, no dependencies. I want partner sites with limited technical resources to be able to read the entire plugin in an afternoon and understand it.

    Decision 1: two blocks instead of three

    The first version had three blocks: Pet Listing Grid, Pet Card, and Featured Pets. The grid and the featured-pets blocks did almost the same thing — both rendered a card grid, differing mainly in whether the order was randomized and whether the count was big or small. After looking at them side by side for a day, I deleted the listing grid.

    The reasoning: if the partner site wants a “browse all” experience, the right answer is to link to the shelter’s own adoption catalog at
    vcpahumane.org/adopt/pets, not to rebuild that catalog locally. The shelter already has filters, pagination, and a real adoption flow over there. The companion plugin’s job is to be a friendly handoff to the shelter, not to duplicate the destination.

    That left two blocks with genuinely different jobs:

    • Featured Pets — a small rotating selection. Sidebar surface. Browse-y.
    • Pet Card — a single specific pet, embedded inside a blog post or fundraiser page. Connection-y.

    Once I framed them as “browse” and “feature,” the design choices for each became obvious in different directions.

    Decision 2: Pet Card is a profile, not a card

    The original Pet Card block looked like the cards in the Featured Pets grid — just bigger. That felt wrong as soon as I had both on a page together. A visitor encountering Featured Pets is browsing; they want a thumbnail to click. A visitor encountering Pet Card is reading a blog post about a specific animal; they’re already interested. Optimizing both for click-through made the second one feel cheap.

    So I rewrote Pet Card as a magazine-style profile: big image on the left (or top, on mobile and via a sidebar toggle), the shelter’s full writeup — not a 22-word excerpt — and a real pill-shaped CTA button that says “Start the adoption process” instead of a subtle “Meet me →” link. Same data, different surface, different intent.

    The two blocks now share almost no markup. Featured Pets uses a shared vcpa_pet_companion_render_card() helper for visual consistency across its cards; Pet Card has its own render function with its own CSS class prefix (.vcpa-pet-profile). They diverged cleanly.

    Decision 3: the adopted-state celebration

    This is the part I’m most fond of. The source plugin removes pets from the shelter’s site when they’re adopted (because they’re no longer adoptable). For Featured Pets that’s fine — the grid just shows whoever’s currently available. But for Pet Card, where a partner site has specifically embedded this one pet in a blog post, it’s a problem: the post breaks. The plugin fetches an empty result and the block disappears or shows an error.

    The fix is small but feels much bigger. When an editor picks a pet in the sidebar, the picker’s “save” handler doesn’t just store the pet’s ID — it also stores the pet’s name, image URL, and alt text as block attributes:

    function pickPet( pet ) {
        setAttrs( {
        petId: pet.id,
        petSlug: pet.slug,
        petName: pet.name,
        petImage: pet.image,
        petAlt: pet.alt
        } );
    }

    Block attributes are persisted in the post content, not in the database separately. They survive forever. So even after the source site forgets about Bagel, the block itself still knows Bagel’s name and what they looked like.

    When the live REST query comes back empty AND there’s a cached name in the attributes, the render falls into a celebration branch instead of the empty branch:

    if ( ! empty( $result['pets'] ) ) {
        // Normal profile render.
        return;
    }
    
    if ( $cached_name !== '' ) {
        // The pet existed at pick time but is gone now.
        // They were almost certainly adopted. Celebrate.
        printf(
            '<h2 class="vcpa-pet-profile__name">%s</h2>',
            esc_html( sprintf( __( '%s found a home!' ), $cached_name ) )
        );
        return;
    }
    

    The block transforms from “Bagel needs a home” into “Bagel found a home!” without anyone touching the post. A blog post written a month ago about a pet who needed adoption becomes, automatically, a blog post about a pet who found one.

    There’s no webhook, no deletion event, no inverse-sync. Just block attributes doing what they’re already designed to do: remembering things.

    Decision 4: no build step

    I made this call partly out of laziness and partly out of conviction.

    Laziness first: a build step means package.json, node_modules, @wordpress/scripts, a webpack config, watching for changes during development, remembering to commit the built artifacts (or not, and adding a CI step), making sure the build environment matches between dev and prod. For two blocks with five UI controls each, that’s a lot of yak-shaving.

    Conviction second: I wanted partner sites — small nonprofits, vet clinics, businesses — to be able to read the entire plugin without needing to understand a build pipeline. If someone wants to fix a typo in a label or adjust a spacing value, they should be able to open the file in any editor and have the change take effect on save.

    So edit.js is hand-written using wp.* globals:

    ( function ( wp ) {
        var el = wp.element.createElement;
        var registerBlockType = wp.blocks.registerBlockType;
        var InspectorControls = wp.blockEditor.InspectorControls;
        // ...
        registerBlockType( 'vcpa-pet-companion/featured-pets', {
            edit: function ( props ) {
                return el( 'div', useBlockProps(),
                    el( InspectorControls, {}, /* ... */ ),
                    el( ServerSideRender, {
                        block: 'vcpa-pet-companion/featured-pets',
                        attributes: props.attributes
                    } )
                );
            },
            save: function () { return null; }
        } );
    } )( window.wp );
    

    No JSX. No imports. Verbose wp.element.createElement calls instead of JSX angle brackets. It is genuinely uglier than the JSX version. But it runs straight from a .js file with no transformation, and that property is worth more to me than the prettier syntax.

    The gotcha that took an hour to find

    There is one nasty thing about the no-build-step approach that I want to warn other developers about, because it isn’t well-documented anywhere.

    When you point editorScript at file:./edit.js in block.json, WordPress looks for a sibling file called edit.asset.php to find out what JavaScript dependencies the script needs. That asset file is normally generated by @wordpress/scripts at build time and contains something like:

    <?php return [
        'dependencies' => [ 'wp-blocks', 'wp-element', /* ... */ ],
        'version'      => '0.2.0',
    ];
    

    If you don’t have a build step, you don’t have an asset file. WordPress reacts to this by enqueuing your script with zero dependencies. Your script will run, but window.wp is not guaranteed to be defined when it does — the load order is essentially undefined. On some sites it works because another plugin has already loaded wp-blocks; on other sites it throws Cannot read properties of undefined (reading 'element') and your blocks never register.

    The fix is to hand-write edit.asset.php next to your edit.js:

    <?php return [
        'dependencies' => [
            'wp-blocks',
            'wp-element',
            'wp-block-editor',
            'wp-components',
            'wp-server-side-render',
            'wp-i18n',
        ],
        'version' => '0.2.2',
    ];
    

    That’s it. Two-line PHP file per block, declaring the wp-* packages your script touches. WordPress reads it, enqueues those scripts as dependencies, and load order works correctly.

    I shipped 0.2.0 without these files because it worked locally. The first production install reported “blocks aren’t showing up” within an hour of the push. 0.2.1 added the asset files. If you’re going down the no-build-step road, don’t repeat my mistake.

    Decision 5: two-layer caching with split negative TTLs

    The source plugin syncs with Petstablished — the shelter’s adoption software — once a day. So the upstream data only changes once every 24 hours, no matter how often the companion plugin asks. That means the companion’s cache TTL can be generous: I set it to 6 hours by default, which gives a same-day window for adopted-pet celebrations to appear without making partner sites pay for unnecessary refetches.

    The more interesting part is the negative cache. When a fetch fails, we store a sentinel [ '__error' => $message ] value in the same transient slot, so the next request fails fast instead of paying another timeout. Without negative caching, if vcpahumane.org goes down, every visitor on every partner site pays a full 8-second timeout until the source recovers. With it, only one visitor per minute does.

    But the first version of the negative cache had a bug. It treated all failures the same way and cached them for 60 seconds. That’s appropriate for real failures (HTTP 500, malformed JSON) — those mean the source is genuinely broken and we should back off. But it’s wrong for network-layer failures, especially in the editor.

    Here’s the symptom: change a Featured Pets block’s count slider in the editor. The new value triggers a new REST request. That request hits a transient loopback timeout (Local by Flywheel’s PHP-FPM worker pool sometimes wedges on self-referential REST calls). The negative cache stores the error for 60 seconds. You change the slider again — now everything fails for a full minute, even though the source is fine, because the editor is hitting the poisoned cache slot.

    The fix is to split the negative TTL by error category:

    // Soft failure: cURL timeout, DNS hiccup, connection refused.
    // These usually resolve in seconds. Cache briefly.
    private const NEGATIVE_TTL_SOFT = 5;
    
    // Hard failure: HTTP 4xx/5xx, malformed JSON.
    // Source is genuinely broken. Cache for a minute.
    private const NEGATIVE_TTL_HARD = 60;
    

    Five seconds is long enough to absorb a hot-loop retry storm, short enough that the next user interaction succeeds. Sixty seconds for real failures still protects against an outage.

    Block style variants vs. core block supports

    WordPress 6.x gives you two complementary mechanisms for letting editors customize blocks: block supports (color, spacing, border, typography) and block styles (named visual variants registered via register_block_style()). I use both, but for different things.

    Featured Pets gets three named styles — Card, Minimal, Overlay — because those are structural differences. Card has a border and a white background; Minimal removes both; Overlay positions the title over the image with a gradient. These aren’t sliders; they’re discrete looks that an editor picks from a thumbnail strip.

    Both blocks also use core supports for color, spacing, and border so that partner sites can theme the output to match their brand. A vet clinic with a teal theme can give the cards teal borders and a teal CTA without writing any CSS.

    Pet Card doesn’t get block style variants. The reason is that “Minimal” or “Overlay” don’t really make sense for a profile — what would they be? Instead it gets a layout toggle (image left vs. image top) and a “show full story” toggle. Both are real choices an editor might want to make; neither needs to live in a styles picker.

    The general principle: block styles for genuinely discrete visual variants, sidebar controls for everything else. If you find yourself making block styles that differ only in one CSS property, those should probably be a sidebar toggle instead.

    What’s still open

    Things I deliberately didn’t build, in case anyone wants to send a PR:

    1. Taxonomy filter is OR, not AND. The wp/v2 REST API accepts comma-separated term IDs as OR filters. To do AND (“good with kids AND good with cats”) would require either a custom REST endpoint with tax_query.relation = AND or client-side filtering after a wider fetch. Not impossible; not done.
    2. No pagination on Featured Pets. The block is meant to be small (1–12 pets), so pagination feels overkill. But if someone wanted to use Featured Pets as a “browse all” surface despite my earlier reasoning, they’d need it.
    3. No gallery support. Petstablished returns multiple images per pet, but the source plugin currently only stores the first as a featured image. To surface a gallery in Pet Card, the source plugin would need to also cache the additional URLs. That’s a cross-plugin change I haven’t tackled.
    4. No editor preview JS-side fetching. Both blocks render their editor preview via ServerSideRender, which means every attribute change triggers a server roundtrip that itself makes a remote HTTP call. On slow hosts this feels sluggish. The fix is to fetch from the editor directly (using apiFetch against the source’s REST API with CORS) and render locally — but that’s significantly more code, and the current approach is good enough for the data sizes involved.
    5. No tests. The plugin is small enough that the cost of a test harness exceeds the cost of breaking changes I’d catch. If it grows, that calculus flips.

    If any of these sound interesting, the repo welcomes issues and pull requests at any size — even typo fixes and CSS tweaks. It’s a small project for a small shelter, and I’d rather have a plugin that improves in small steps than one that’s untouched because the bar to contribute feels high.

    Why this exists at all

    The companion plugin is the second smaller plugin I’ve built around the same core piece of work — a Petstablished sync plugin that lives on vcpahumane.org and handles the actual data ingestion, custom post type registration, faceted browsing, and adoption flow integration. That one is the more architecturally interesting of the two, and I’ll write about it in a future post.

    But the companion plugin is the part that spreads. The sync plugin serves one site; the companion plugin can serve any number of partner sites, each one becoming another place where adoptable pets show up when someone is looking. That’s the part that scales. And the part that scales is the part most worth doing carefully.