Blog

  • Help Venango County’s adoptable pets find homes from your own website


    The Venango County Humane Society is a small no-kill shelter in northwestern Pennsylvania with room for fewer than sixty animals at a time. Most of the dogs and cats who come through find homes with families nearby, but every so often someone drives a long way to meet a pet they first saw online — which is the whole reason this post exists.

    Every adoption starts the same way: someone, somewhere, sees a pet and stops scrolling. The more places those pets show up, the more chances they have to be seen.

    So I built a small WordPress plugin that lets any WordPress site display adoptable pets from VCHS. If you run a website — a local business, another nonprofit, a vet clinic, a personal blog — you can drop a block onto a page and visitors will see real pets currently looking for homes. Click any of them and they’ll be sent straight to the shelter’s adoption page to start the process.

    Here’s what it looks like in action. These pets are live right now:

    That’s not a screenshot. Those are actual animals at the shelter, fetched from vcpahumane.org as you loaded this page. When one of them finds a home, they’ll quietly disappear from the grid and someone new will take their place.

    What this is

    The plugin is called VCPA Humane Pet Companion and it ships two Gutenberg blocks that you can drop into any post or page. The shelter handles all the data — keeping pet listings up to date, syncing with their adoption software, writing the descriptions — and your site just displays whatever’s currently available. There’s nothing for you to maintain.

    Both blocks pull live data, so you always show animals who are still looking. No stale listings, no manual updating, no risk of featuring a pet who’s already been adopted.

    Block 1: Featured Pets

    This is the block above. It’s meant for sidebars, homepages, footer sections — anywhere you want a small rotating selection of adoptable pets to share space with the rest of your site. You can choose how many pets to show, how many columns, and whether to randomize the order. You can also filter by animal type (dogs, cats) or by traits like “good with kids” or “special needs,” so a
    cat-focused business can show only cats and a senior-pet advocate can highlight the older animals who are easiest to overlook.

    It’s the browse surface. Visitors who like what they see click through to vcpahumane.org and continue from there.

    Block 2: Pet Card

    The second block is for when you want to feature one specific pet — maybe you’re writing a blog post about a fundraiser, or a particular animal has captured your attention, or you want to give a long-stay resident a moment of extra visibility. The Pet Card block embeds a single pet as a profile, with the shelter’s full writeup and a clear button to start the adoption process.

    Joker

    Joker

    Meet Joker! This sweet boy is looking for a forever home to call his own. Joker has FeLV, or Feline Leukemia, and would need to be the only cat in the home or in a home with other FeLV+ cats. This is not contagious to humans or other species of animals. This does, however, mean his health would need to be monitored closely in cooperation with your veterinarian in order to keep on top of anything that may arise. He is very friendly, playful, and would be a wonderful companion for a family looking to open their home to a new member. If you think Joker is the right fit for your family, apply today! DOB: 8/9/2019

    Start the adoption process

    Here’s the part I’m most fond of: when a featured pet finds a home and is removed from the shelter’s listings, the block doesn’t break. It quietly transforms into a celebration card — “[Name] found a home!” — using the pet’s name and photo, which the block remembers from when you first picked them. You don’t have to update anything. A blog post you wrote a month ago about a pet who needed a home becomes, automatically, a blog post about a
    pet who found one.

    That feels like the right way to handle adoption: not as data getting deleted, but as good news.

    How to install

    Three steps, no code:

    1. Download the plugin from github.com/jwincek/vcpahumane-pet-companion (use Code → Download ZIP).
    2. Upload it to your WordPress site through Plugins → Add New → Upload Plugin.
    3. Activate it. That’s it. The blocks will appear in the editor under
      “VCPA Featured Pets” and “VCPA Pet Card.”

    There’s nothing to configure. The plugin is preset to talk to vcpahumane.org right out of the box.

    What it costs

    Nothing. No fees, no API keys, no signup, no tracking. The plugin is GPL licensed and the source is public. The shelter is a small nonprofit and this is a small contribution to making their pets more findable.

    If you have a website and even a corner of it that could host a featured pets block, please consider it. Every additional place a pet shows up is another chance for someone to stop scrolling.


    For developers: the plugin is intentionally small and built without a
    build step. If you’d like to read about how it’s put together — including the design decisions, the gotchas, and the things still left to do — I wrote a longer post about it: Building the pet companion plugin →.

    If you’d like to help, the code is on GitHub and contributions of any
    size are welcome.


    Photos and pet data courtesy of the Venango County Humane Society.

  • Simple Spam Shield: a lightweight, cloud-free WordPress anti-spam plugin


    This is the last post in a series about plugins I’ve built for the Venango County Humane Society. The previous five posts covered shelter-specific WordPress work — adoptable pet listings, a Petstablished sync engine, a donations platform, a recurring events manager. All of them are specialized for nonprofit use cases; none of them would make sense outside of that context.

    This post is about the one plugin in the stack that has nothing to do with animals, donations, or shelters. It’s a spam plugin. It’s called Simple Spam Shield, and the only connection to the shelter is that it was born from a shelter problem.

    Here’s the problem. Within weeks of being posted, the shelter’s volunteer application form — built using Jetpack’s Contact Form blocks — started receiving thousands of fake submissions. Casino spam, pharma spam, SEO link dumps, the usual. So many that I had to wade through hundreds of pages of garbage to find real volunteer applications. We were using WordPress blacklisted terms at the time.

    The obvious answer was Akismet. It’s the default WordPress spam
    solution, it’s free for personal sites, and it works well. But it sends every form submission to Akismet’s cloud service for analysis, which means every volunteer applicant’s name, email address, and personal information passes through a third-party server. For a nonprofit that handles sensitive community data, that felt wrong. The alternative — installing a commercial anti-spam plugin with a $50/year license — felt like the same paywall dynamic I’d been building around in the other plugins.

    So I built a spam shield. No cloud. No API keys. No external
    dependencies. Everything runs locally on the WordPress server. It
    protects comments, WooCommerce product reviews, and Jetpack Contact Forms using a small bundle of proven mechanisms that don’t require phoning home. It’s been running on vcpahumane.org for a few weeks as the site’s only spam protection.

    The repo is at github.com/jwincek/simple-spam-shield. GPL, zero-config out of the box, designed to be useful to any WordPress site that wants spam protection without a cloud dependency.

    What “simple” means

    The name is deliberate. “Simple” doesn’t mean the code is a single
    file — it’s a few thousand lines of PHP with config-driven guard
    definitions, a normalized data pipeline, and per-surface integration classes. “Simple” means the feature selection is small and focused: seven spam-detection mechanisms, three protected surfaces, one settings page, one log viewer. No machine learning, no remote API, no cloud dashboard, no premium tier.

    The thesis is that a handful of well-implemented local checks —
    honeypot fields, time gates, duplicate detection, nonce validation, link counting, keyword matching, and optional behavioral scoring — catch the vast majority of automated spam without needing to send user data off-server. They don’t catch everything. A determined human spammer will get through. But for the kind of automated form-crawling that floods small sites, they’re effective and they preserve privacy.

    The guard pipeline

    The plugin’s core is a weighted, short-circuit pipeline called the
    Guard_Runner. Seven guards are defined in config/guards.json, each with a weight that determines execution order. The runner sorts guards by weight (highest first), initializes them, and runs them sequentially against the submission data. The first guard that fails blocks the submission — no further guards run.

    GuardWeightDefaultWhat it does
    Honeypot100OnHidden form field that bots fill in, humans don’t
    Duplicate95OnMD5 hash of content + author + email + IP, checked against a 60-second transient window
    Time Gate90OnRejects submissions faster than 3 seconds after page load
    Nonce80OnStandard WordPress nonce verification
    Link Limit70OnRejects submissions with more than 3 URLs
    Keyword Block60OnCase-insensitive matching against a blocklist
    Behavioral55OffScores mouse movements, clicks, and time on page

    The weight ordering is intentional: cheap checks run first. The
    honeypot is a single empty-field check — essentially free. The
    duplicate guard is a transient lookup. The time gate is arithmetic.
    By the time the runner reaches keyword matching (which involves
    string operations against a list), most bot submissions have already been caught and rejected by a faster check.

    All seven guards implement a shared Guard_Interface and extend Abstract_Guard, so adding an eighth guard is a matter of writing one class and adding an entry to guards.json. The pipeline doesn’t need to know about the new guard’s internals — just its weight and whether it’s enabled.

    Three surfaces, one normalized layer

    The plugin protects three different form systems, each with its own submission lifecycle:

    • WordPress comments — intercepted via the preprocess_comment filter at priority 1
    • WooCommerce product reviews — intercepted via
      woocommerce_new_comment (reviews are stored as comments but go through WC’s own flow)
    • Jetpack Contact Forms — intercepted via
      jetpack_contact_form_is_spam filter

    Each surface has a thin integration class in includes/integrations/ whose only job is to normalize the submission data into a common format — content, author, email, plus any JS-injected fields (honeypot value, nonce, timestamp) — and pass it to the Guard_Runner. The guards never need to know which surface the data came from.

    This is the same pattern as the Petstablished sync plugin’s abilities layer: a normalized interface between the outside world and the core logic, so the core logic stays clean and testable regardless of how many input surfaces you add. If someone wanted to add protection for Gravity Forms or WPForms, they’d write one integration class, normalize the data, and the existing seven guards would apply automatically.

    The Jetpack problem (and the two-phase workaround)

    Jetpack Contact Forms are the hardest surface to protect, and the reason is worth documenting because other plugin developers will run into the same wall.

    When a Jetpack form is submitted, Jetpack’s processor recognizes only the fields defined in the form’s configuration. Any extra fields that the plugin injects — the honeypot field, the nonce, the timestamp, the behavioral data — are silently stripped
    from $_POST before Jetpack’s spam filter fires. By the time the
    plugin’s jetpack_contact_form_is_spam filter runs, the JS-injected guard data is gone.

    The solution is a two-phase pipeline:

    Phase 1 runs before Jetpack processes the form. At this point,
    raw $_POST still contains the injected fields, so JS-dependent
    guards (honeypot, nonce, time gate, behavioral) can check their
    data and set a rejection flag.

    Phase 2 runs during Jetpack’s own spam filter. If Phase 1 already flagged the submission, it returns immediately. Otherwise, it runs content-based guards (keyword block, link limit, duplicate) against Jetpack’s structured form data — which is available at this
    point even though the JS fields aren’t.

    The guards themselves handle the edge case gracefully: if a guard expects a field that’s missing (because Jetpack stripped it), it skips rather than hard-failing. This means even if Phase 1 doesn’t fire for some reason, the content-based guards still protect the form. Defense in depth, with graceful degradation.

    This is the kind of integration problem that doesn’t show up in
    documentation or tutorials. Jetpack’s field-stripping behavior is
    undocumented and invisible until you try to inject custom fields
    into a form submission and watch them disappear. If you’re building any plugin that needs to intercept Jetpack form data, plan for this.

    The keyword list problem

    Let me be honest about the plugin’s current biggest weakness: the default keyword blocklist has seven entries.

    ‘casino, poker, viagra, cialis, crypto airdrop, free money, click here now’

    Seven words. That’s the out-of-the-box protection against
    keyword-based spam. The other six guards (honeypot, time gate,
    nonce, duplicate, link limit, behavioral) don’t depend on keywords
    and work fine, but keyword blocking is the guard that catches
    content-aware spam — the submissions that are crafted to look
    human-like but contain telltale phrases. Seven keywords is not
    enough for that.

    The reason it’s seven is that I’ve been cautious about false
    positives. Every keyword added to the default list is a keyword
    that could block a legitimate submission on someone’s site I’ve
    never seen. “Casino” is safe — no legitimate volunteer application
    mentions casinos. But “free” alone would block real content. “Buy”
    would block WooCommerce review discussions. The default list needs to be universally safe, which means it needs to be conservative, which means it’s small.

    This is the single most useful contribution someone could make to this plugin right now: a curated, well-tested default keyword
    list that’s aggressive enough to catch common spam patterns but
    conservative enough to avoid false positives on typical WordPress
    sites. If you maintain a WordPress site and have access to your
    spam folder, the phrases in there are exactly what this list needs.
    Open an issue, paste the patterns, and I’ll merge them.

    The admin can add site-specific keywords from the settings page
    (it’s a textarea, one keyword per line), but the defaults should
    be good enough that most sites don’t need to touch them.

    Allowlisting and the privacy model

    Before any guard runs, the pipeline checks the submitter’s IP
    and email against an allowlist. The allowlist supports exact IPs,
    CIDR ranges (e.g., 10.0.0.0/8), exact email addresses, and
    domain patterns (e.g., @trusted.org). Allowlisted submissions
    bypass all guards entirely.

    The broader privacy model is simple: no data leaves the server.
    Form submissions are checked locally. Blocked attempts are logged to a custom database table on the site’s own database. The log captures the guard that triggered, the reason, a content excerpt, the IP, and the user agent — enough to diagnose false positives, not enough to build a surveillance profile.

    The log can be disabled entirely from the settings page, and
    uninstall.php drops the log table and deletes all plugin options and transients. A clean uninstall leaves no orphaned data behind.

    This is the part of the plugin I feel most strongly about. Spam
    protection and privacy should not be in tension. The reason cloud-based spam services exist is that they can aggregate data across millions of sites to build better models — and that’s genuinely effective. But for a small nonprofit handling volunteer applications and donation forms, the tradeoff of sending that data to a third party isn’t worth it. A local-only approach is good enough for the threat model, and it respects the people filling out the forms.

    What’s still open

    Features that would make the plugin meaningfully better, in rough
    order of impact:

    1. A larger, better-curated default keyword list. I said this above but it bears repeating: this is the highest-leverage contribution anyone can make. The plugin’s architecture is solid; its vocabulary is anemic. If you have a collection of spam phrases from your own site, please share them.

    2. A “block this” button in the log viewer. Currently, if an admin sees a blocked submission and wants to add the offending
    phrase to the keyword list, they have to copy it, navigate to
    settings, paste it into the textarea, and save. A one-click “add to blocklist” action from the log viewer would close that loop.

    3. A “whitelist this” button in the log viewer. Same idea: if a legitimate submission was blocked, the admin should be able
    to allowlist the IP or email directly from the log entry.

    4. Gravity Forms / WPForms / CF7 integrations. The normalized
    data layer makes this straightforward — one integration class per
    form plugin. I’ve only built the three surfaces I needed (comments, WC, Jetpack). If you use a different form plugin and want to contribute an integration, the architecture is ready for it.

    5. A community-maintained blocklist. The most ambitious
    version of item 1: a shared, versioned keyword list that sites can
    subscribe to (via a simple GitHub-hosted JSON file, not a cloud
    service). Sites would pull keyword updates on a schedule without
    sending any data back. This preserves the no-cloud model while
    benefiting from collective intelligence. Not built yet, but the
    architecture would support it cleanly.

    Why this is the last post in the series

    This series started with a post about a pet adoption plugin — the kind of thing that only makes sense if you know about the Venango County Humane Society specifically. It ends with a spam
    plugin that has nothing to do with animals, shelters, or
    northwestern Pennsylvania.

    That’s the arc I want to name explicitly, because I think it
    generalizes.

    When you start building for a specific organization — especially
    a small nonprofit doing cost-critical work that shouldn’t need to buy solutions off the shelf — you build the specific things first. A pet sync engine. A donation platform. An events manager. Each one is tailored to one organization’s needs, and each one is useful to other organizations with similar needs. The circle of relevance is small but real.

    But along the way you inevitably build generic things too. A spam
    plugin. A caching pattern. A config-driven registration framework.
    An edit.asset.php file that you document in a blog post so the
    next developer doesn’t lose an hour to the same gotcha. These are the by-products of specific work that turn out to be useful to
    everyone.

    I think this is how open-source nonprofit infrastructure actually
    gets built. Not by someone deciding to build “a platform for
    nonprofits” in the abstract, but by someone solving real problems
    for a real organization, publishing the solutions, and discovering
    that the specific and the generic are interleaved in ways you
    can’t predict in advance.

    Six plugins, six posts, one shelter. If any of them are useful to
    you — whether you run a shelter, a nonprofit, a small business, or
    just a WordPress site that gets too much spam — I’m glad. And if
    you want to help make any of them better, the repos are all open
    and I’ll be here.


    The repos:

    All GPL. All welcome contributions of any size.


    Thank you for reading this series. If you’d like to start from the beginning, the first post is here.

  • Managing shelter programs with WordPress and The Events Calendar


    The Venango County Humane Society runs weekly programs. Bingo every Tuesday and Saturday night. A feline spay and
    neuter clinic every Tuesday morning. Adoption events, fundraisers, volunteer orientations, education sessions — most of them recurring, most of them indefinite, all of them needing to appear on a public calendar so donors, volunteers, and community members know when and where to show up.

    The shelter already used The Events Calendar for its public calendar view — it’s the most popular event plugin in WordPress, well-maintained, and the free version handles everything you’d expect from an event display. Except for one thing: recurring events. That feature is paywalled behind The Events Calendar Pro,
    which starts at around $100 a year. For a shelter with a strict
    operating budget, that’s a real decision — and for a pattern as simple as “bingo every Tuesday and Saturday, forever,” it’s a lot of money to spend on a calendar.

    The natural workaround is The Events Calendar’s CSV import. You can build a spreadsheet with one row per event instance, upload it, and TEC will create the events. This works. It also means that every quarter, the front desk manager has to open a spreadsheet, duplicate last week’s bingo row thirteen more times, fix the dates, re-upload, and hope nothing went wrong. A recurring service generates 100+ events a year, and the import file has to be maintained as a rolling document. It’s not hard, exactly. It’s just constant, and it’s the kind of maintenance work that has no upside — it just has to get done.

    This post is about the plugin I built to make that go away. It’s at
    wp-content/plugins/vcpahumane-shelter-events-wrapper/ in the site’s tree, available as a GPL project at
    github.com/jwincek/vcpahumane-shelter-events-wrapper,
    and it went live on vcpahumane.org about two weeks ago. Currently two people edit events with it: me and the shelter’s front desk manager.

    The plugin is a thin authoring layer on top of The Events Calendar.
    You define a program once (“Bingo, every Tuesday and Saturday, 5 PM to 9 PM, at the shelter”), and the plugin generates individual TEC events for each occurrence on a rolling schedule. TEC continues to handle the public calendar display — this plugin doesn’t touch the front-end — but the authoring experience is now “describe the pattern” instead of “maintain a spreadsheet.” That’s the entire pitch.

    Is it a wrapper or an extension?

    The plugin is called “shelter-events-wrapper,” but reading the code I’d actually describe it as an extension alongside TEC, not a
    literal wrapper. The admin doesn’t stop using TEC’s interface — they still see TEC’s events list, still click into individual event posts
    to edit them when needed, still use TEC’s categories and calendar
    views on the front end. The plugin adds a second authoring surface (the Programs CPT) that generates TEC events but doesn’t replace TEC’s own editing.

    This matters because the plugin inherits TEC’s strengths for free.
    Every TEC extension in the ecosystem — integrations with booking
    systems, email marketing, social media sharing — keeps working. The calendar views, archive pages, and single-event templates remain TEC’s, which means they get TEC’s updates and accessibility improvements without anyone having to backport anything.

    It also means the plugin has a narrower responsibility: authoring
    recurring programs. It doesn’t try to be a better calendar. It just
    tries to be a better admin experience for the specific case of
    defining recurring services that run indefinitely.

    Decision 1: programs as a custom post type, events as the output

    The centerpiece is a custom post type called shelter_program. Each program has the fields you’d expect — title, description, recurrence pattern, start and end times, timezone, venue, organizer, cost — plus a few nonprofit-specific additions: a contact email, an “appointments required” flag, age restrictions, a “variable pricing” checkbox for services like the spay/neuter clinic where cost depends on the animal, and a blackout dates list.

    A program is not an event. It’s a description of a recurring service.
    When the cron runs, or when an admin clicks “Generate Events” on a dedicated page, the plugin walks forward through the calendar and creates individual TEC events for each occurrence that matches the program’s pattern. Those events are stored in TEC’s native tribe_events post type using TEC’s PHP ORM:

    ```php
    $event = tribe_events()
        ->set_args( [
            'title'          => 'BINGO Night — Tuesday, April 15, 2025',
            'description'    => $program['description'],
            'start_date'     => $start,
            'end_date'       => $end,
            'timezone'       => $program['timezone'],
            'venue'          => $venue_id,
            'organizer'      => $organizer_id,
            'cost'           => $program['cost'],
            'featured'       => $program['featured'],
        ] )
        ->create();

    No direct database writes. No template overrides. No wrestling with TEC’s internals. The ORM exists precisely for this use case and it works well. The plugin’s contribution is the authoring UX and the automation; TEC’s contribution is everything else.

    The rolling window is eight weeks by default, configurable in config/events.json. Each daily cron run extends the window forward, creating new events as needed. Nothing is generated indefinitely into the future — just enough for visitors browsing the next two months to see what’s upcoming.

    Decision 2: deterministic hashes for duplicate prevention

    Cron runs, and it runs again, and sometimes it runs a third time because a stale job didn’t get cleaned up properly, and suddenly you have three copies of next Tuesday’s bingo on your calendar. This is the classic failure mode of “generate events from a schedule,” and the standard fix is to make event creation idempotent: running the generator twice should produce the same result as running it once.

    The plugin does this with a SHA-256 hash of the program slug plus the event date, stored as post meta on each generated event:

    private static function make_hash( string $slug, string $date ): string {
        return hash( 'sha256', $slug . '|' . $date );
    }
    
    private static function event_exists( string $meta_key, string $hash ): bool {
        global $wpdb;
        $exists = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*) FROM {$wpdb->postmeta}
                 WHERE meta_key = %s AND meta_value = %s",
                $meta_key,
                $hash
            )
        );
        return (int) $exists > 0;
    }
    

    Before creating an event for a given program on a given date, the generator hashes the pair and checks whether an event with that hash already exists. If yes, skip. If no, create and stamp the hash onto the new post.

    This is simple enough that it sounds almost beneath mention, but the alternatives are worse: a unique compound meta key requires custom table creation; a title-based check breaks the moment you change a program’s name; a “last generated” timestamp on the program doesn’t handle partial runs. The hash approach is idempotent, storage-efficient, and fails safely — if somehow an event does get duplicated, the worst that happens is you delete one manually and the check prevents the next cron run from recreating it.

    Decision 3: the Replace workflow

    This is the decision I’m most pleased with, because it came directly from a problem I didn’t anticipate.

    Here’s the problem: programs run forever, but reality intervenes. “Bingo is cancelled this week because of the holiday.” “This month’s Feline Clinic is being replaced with a special shots-only session.” “Tuesday’s event is happening in a different venue because we’re renovating the main hall.” The recurring definition is still correct — bingo will resume next week — but one specific occurrence needs to change or be replaced.

    A naive solution is to let the admin edit the individual generated event in TEC’s interface. That works until the next cron run overwrites the edit, because the syncer sees a program-linked event that’s out of sync with its program definition and “corrects” it.

    The plugin solves this with an explicit Replace workflow. From the Generate Events admin page, each upcoming event gets a “Replace” action that does three things:

    1. Cancels the original event. It’s not deleted — the post stays in place with [CANCELLED] prepended to the title and a _shelter_cancelled meta flag set. This preserves a historical record and keeps the cross-references intact.
    2. Creates a brand-new draft event. The new event is pre-populated with the original’s date, time, venue, and organizer, but it’s otherwise a blank slate — the admin has full creative freedom to change the title, description, featured image, category, and anything else.
    3. Stores cross-references. The original gets a _shelter_replaced_by meta pointing to the new event’s ID; the new event gets a _shelter_replaces_event meta pointing back to the original. The Event Syncer respects this linkage — it will never re-sync a replaced event from the program definition, so the creative edits are safe from future updates.

    After clicking Replace, the admin is kicked into TEC’s native block editor for the new event. They can use TEC’s categories, meta, and content tools normally — nothing in the replacement workflow is proprietary to this plugin. When they publish, the new event appears on the calendar in place of the cancelled one. Two weeks later, the regular Tuesday bingo resumes without any further intervention.

    This is the feature that turns “a better way to create recurring events” into “a real tool for managing a recurring program.” Anyone building event tooling for a nonprofit will eventually run into “this week is different,” and having a thoughtful answer to that question is the difference between a demo and a production tool.

    Decision 4: weekly recurrence only (and why that’s probably wrong)

    Let me flag a real limitation honestly. The plugin only supports weekly day-of-week recurrence — “every Tuesday and Saturday” — and nothing more complex. It doesn’t handle “second Saturday of the month,” “last Friday of every month,” “every other Tuesday,” “alternating Mondays and Wednesdays,” or any other pattern that can’t be expressed as a fixed set of weekdays repeating every week.

    For VCHS’s current programs this is fine. Bingo is every Tuesday and Saturday; the clinic is every Tuesday. None of the shelter’s programs need anything more sophisticated right now.

    But I already know this won’t last. The moment the shelter adds a monthly fundraiser or a “second Saturday of the month” volunteer orientation, the plugin will fall over and I’ll have to extend it. The cleanest way to handle this is probably to adopt RFC 5545 RRULE syntax — the same standard iCalendar uses — which supports all of those patterns cleanly and has battle-tested PHP libraries. But that’s a nontrivial refactor of the metabox, the generator, and the UI, so I’ve punted on it for now.

    Worth knowing if you clone the plugin and your use case is more complex than “specific weekdays every week.” You’ll hit this wall faster than I did.

    Decision 5: the small UX details that matter for non-technical users

    One of the two editors using this plugin is the shelter’s front desk manager, who is not a developer and didn’t ask to be one. This shaped several small UX decisions that are worth noting because they’re the kind of thing that doesn’t make it into most technical posts.

    Day-of-week chips instead of a text field. The recurrence pattern is set by clicking toggle buttons for each weekday. Not a comma-separated list, not a dropdown, not an RRULE string — visual pill buttons that say “Mon Tue Wed Thu Fri Sat Sun” and light up when clicked. Anyone can understand this without explanation.

    “Dry run” preview on the generate page. Before actually creating events, admins can click “Generate (dry run)” to see a preview of what would be created. This makes the generator feel safe to experiment with. Running it accidentally doesn’t break anything; it just shows you a list.

    “Also update past events” checkbox, off by default. When you change a program — say, you correct a typo in the description, or change the venue — by default only future events are updated. Past events are frozen as historical record. The opt-in checkbox lets you override that when you actually want to retroactively fix past events, but it’s not the default because the default should preserve history.

    Cancelled events stay visible, with a badge. When an event is cancelled (either individually or via Replace), it isn’t deleted — it stays on the calendar with a “Cancelled” badge. This way anyone looking at the calendar retrospectively can see “yes, bingo was supposed to happen on March 5th, and it was cancelled, not missed.” For a recurring community program, that matters.

    The Staff Guide, rendered from README.md inside WordPress admin. There’s a dashboard page under Events → Staff Guide & Help that renders the plugin’s README.md as HTML. The parser is hand-rolled (it handles the specific Markdown subset the README uses — headings, lists, tables, code blocks, emphasis, links — nothing fancier) and has no external dependencies. The front desk manager can find help without leaving WordPress, without visiting GitHub, without knowing that GitHub exists. For a plugin with two users, one of whom is not technical, this matters more than any code-level architectural decision.

    None of these are flashy. None of them are “WordPress 6.9 Abilities API” or “Block Bindings” or any of the novel-sounding things. But they’re the decisions that determine whether the front desk manager actually uses the plugin or reverts to the spreadsheet. Technical sophistication without UX thoughtfulness is just a more elaborate spreadsheet.

    A warning about get_posts() and TEC venues

    This is a “small gift to the next developer” section, same spirit as the edit.asset.php gotcha from the companion plugin post.

    When creating TEC events from a program, the plugin needs to reuse existing venues and organizers if they already exist with the same name — otherwise every sync creates duplicate venues, and you end up with forty-seven “Venango County Humane Society” entries in the venues list.

    The natural way to look up a venue by name is:

    $existing = get_posts( [
        'post_type' => 'tribe_venue',
        'post_title' => $venue_name,
        'post_status' => [ 'publish', 'draft' ],
        'posts_per_page' => 1,
    ] );
    

    This does not work. get_posts() silently ignores the post_title parameter — it’s not a supported WP_Query argument, despite looking like it should be. get_posts() will return the first venue it finds regardless of title, and if there are no venues at all it’ll return an empty array. You won’t get an error. You won’t get a warning. You’ll just get the wrong result, and your plugin will quietly create duplicate venues every sync.

    The fix is a direct $wpdb query:

    $existing_id = $wpdb->get_var(
        $wpdb->prepare(
            "SELECT ID FROM {$wpdb->posts}
             WHERE post_type = 'tribe_venue'
               AND post_title = %s
               AND post_status IN ('publish', 'draft')
             LIMIT 1",
            $venue_name
        )
    );
    

    This is one of those “WordPress quirk” things that’s well-known if you’ve dealt with it before and completely invisible if you haven’t. I lost real time to this during development. If you’re writing any plugin that looks up posts by title, always go through $wpdb directly, not get_posts() or WP_Query — they quietly don’t support title filtering even though the documentation makes it look like they do.

    (As a bonus, the plugin’s title-match query also promotes any matching draft venues to publish status when they’re reused. This avoids a subtle bug where a venue created manually but left as a draft would stay invisible on the calendar even though events were being assigned to it.)

    What’s still open

    Features I haven’t built yet, in rough order of how likely I am to actually need them:

    1. Monthly and more complex recurrence patterns. The weekly-only limitation will bite eventually. RFC 5545 RRULE is the right answer; doing the refactor well is the hard part. If you have experience with RRULE parsing libraries for PHP, I’d love help with this.
    2. A proper date picker for blackout dates. Currently blackout dates are entered as a textarea with one date per line in YYYY-MM-DD format. It works. A calendar widget would be kinder to the non-technical user.
    3. Bulk actions on programs. If you want to pause five programs at once (say, during a facility renovation), you currently have to edit each one individually. A bulk action on the programs list table would be a small, useful addition.
    4. Time-of-day differences within a recurrence pattern. Currently a program has one start time and one end time, so “Bingo is 5 PM on Tuesdays but 7 PM on Saturdays” requires two separate programs. That’s fine for now but it’s a papercut.
    5. An iCal export endpoint. TEC already provides this for its own events, so it’s probably already covered, but worth verifying. If TEC’s iCal doesn’t include shelter-program-generated events correctly, a custom export would be useful.
    6. An optional “import from TEC CSV” migration path for shelters already using CSV imports. It should be possible to detect repeating patterns in an existing CSV file and offer to convert them into program definitions. Not built yet.

    None of these are blocking anyone. The plugin is doing its job for VCHS’s current programs, and the absent features are nice-to-haves rather than dealbreakers. But if you’re a developer who wants to contribute and any of these sound interesting, the repo is open and I’d be happy to pair on design.

    How other shelters could use this

    This is a case study more than a pitch, but I want to say something honest about portability.

    The plugin is written for a specific shelter with specific programs, and the seed data in config/events.json reflects that — it creates bingo and the spay/neuter clinic as starter programs on first activation. For another shelter to use the plugin, the first thing they’d need to do is delete those seed programs and create their own.

    The category taxonomy (Fundraiser, Clinic, Adoption, Education, Volunteer) was chosen for VCHS’s programming but is generic enough that most shelters would find it applicable. Categories are editable from the WordPress admin like any normal taxonomy, so a shelter with different needs can add their own without touching code.

    The plugin’s text domain is shelter-events (not vcpahumane-*) because I wanted it to feel generic enough to apply elsewhere. The UI strings, the field labels, the help text — none of them reference VCHS specifically. A shelter in Idaho could install this plugin and use it without seeing “Venango County” anywhere.

    That said, I haven’t actually tested it at another shelter. If you run a small nonprofit that manages recurring programs and you’re currently wrestling with TEC’s CSV import, I’d love to hear whether this plugin would work for you, and what would need to change to make it usable. Open an issue, send an email, whatever works.


    The repo: github.com/jwincek/vcpahumane-shelter-events-wrapper

    License: GPL

    Requires: WordPress 6.4+, The Events Calendar (free), PHP 8.1+

    Status: Live on vcpahumane.org as of two weeks ago. Two active users. Currently managing two recurring programs generating several events a week; tested at smaller scale than it would ideally support, but no known issues.

    If you’re building similar tools for other nonprofits, I’d love to compare notes. And if you’re a nonprofit and you’d like to try this plugin, I’d love even more to hear whether it actually helps.

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

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