Notes on building RenderWhen for Blocks

I shipped a WordPress plugin this week. It’s called RenderWhen for Blocks, and it does conditional visibility on Gutenberg blocks — show this paragraph only to logged-in users, hide that group block before March 1st, render this CTA only on mobile. It’s the kind of plugin that already has competitors. I built it anyway, because the architecture I wanted to ship doesn’t exist in the existing options, and because I wanted to put a polished plugin on the other side of my Core contribution work.

This post is the things I’d want to read if someone else had built it. The architecture choices, the mistakes, what I’d do differently. If you’re considering installing it, the plugin page has the user-facing story. This is for developers.

Server-side rendering, and why it matters

The differentiator is one architectural choice: when a block’s visibility conditions don’t match, the block is not rendered into the page output at all. It returns '' from the render_block filter. It never reaches the browser.

This is the opposite of what most visibility plugins do. The common pattern is to render the block normally and add display: none via CSS — sometimes server-side based on conditions, sometimes via JavaScript at runtime. That approach has real costs:

  • The hidden content is in the DOM, which means it’s in view-source, which means search engines and AI scrapers see it
  • Page weight includes the hidden content, including any images it references
  • Accessibility tooling has to navigate around invisible-but-present content
  • CSS-based hiding can race against page render, causing flashes of hidden content

Server-side filtering eliminates all of this. The browser never knows the hidden block existed.

The implementation is about 80 lines of PHP. A Block_Renderer class hooks render_block at priority 10, reads a renderWhen attribute that’s been added to every block via register_block_type_args, looks up the condition in the registry, evaluates it, and either returns the rendered HTML unchanged or returns an empty string. The boring code is the right code.

The public Conditions API

RenderWhen ships with three built-in conditions: user state (logged in, logged out, specific roles), date range, and device type. These cover most real use cases. But the actual identity of the plugin is the registry that holds them — because the same registry is exposed as a public extension API.

php

add_action( 'renderwhen_register_conditions', function ( $registry ) {
    $registry->register( new My_Plugin\Conditions\Country_Condition() );
} );

A third-party plugin implements RenderWhen\Conditions\Interface_Condition, registers it through the action, and the condition is now available everywhere — in the block editor’s “Show this block when…” dropdown, in the renderer’s evaluation, in the preview-as feature.

The choice I’m most pleased with: the three built-in conditions are registered through the exact same action that third parties use. The Plugin::init() method hooks renderwhen_register_conditions and the callback registers User_State, Date_Range, and Device. They’re not special-cased internal registrations; they go through the public API like everyone else’s would.

This dogfooding pattern is the difference between an API that’s documented and an API that actually works. Every PHPUnit test I wrote for the registry was also a test of the extension surface. If a third party hits a bug, I’d hit the same bug. The contract is enforced by my own code’s reliance on it.

The boring/hard parts

A few things I learned the hard way that someone else might benefit from.

The Composer autoloader trap

The plugin uses namespaced PHP classes throughout. During development I had Composer autoloading them via vendor/autoload.php. This works perfectly in development and breaks completely in production, for a reason that wasn’t obvious to me until I tested a fresh install.

Users who install plugins via wp-admin → Plugins → Add New don’t have Composer. They get the ZIP, WordPress unzips it, and the plugin runs. If the bootstrap calls require 'vendor/autoload.php' and vendor/ isn’t in the ZIP, the plugin fatals on activation. If you ship vendor/ in the ZIP, your dev dependencies get downloaded by every user.

The fix is a hand-written PSR-4 autoloader, about 30 lines:

php

spl_autoload_register( static function ( string $class ): void {
    $namespace = 'RenderWhen\\';
    if ( 0 !== strpos( $class, $namespace ) ) {
        return;
    }

    $relative   = substr( $class, strlen( $namespace ) );
    $segments   = explode( '\\', $relative );
    $class_name = array_pop( $segments );
    $directory  = $segments ? strtolower( implode( '/', $segments ) ) . '/' : '';

    $filename_base = strtolower( str_replace( '_', '-', $class_name ) );
    $candidates    = [
        "class-{$filename_base}.php",
        "interface-{$filename_base}.php",
        "abstract-{$filename_base}.php",
    ];

    foreach ( $candidates as $candidate ) {
        $path = __DIR__ . '/includes/' . $directory . $candidate;
        if ( file_exists( $path ) ) {
            require_once $path;
            return;
        }
    }
} );

It follows the WPCS file-naming conventions (class-*.php, interface-*.php, abstract-*.php) and resolves the namespace mapping by hand. Production runtime needs no Composer; development can still use the Composer autoloader for tests. They coexist cleanly because spl_autoload_register is additive.

I’d never seen this pattern called out anywhere in WordPress plugin documentation. Most tutorials assume either no namespacing or “just ship vendor/.” The hand-written autoloader is the right answer for plugins that want PSR-4 namespacing without runtime Composer dependency.

The naming review

WordPress.org’s plugin review process flagged my original name as too generic. The plugin was called “Block When” — a name I liked because it phrased the feature literally (“Show this block when the user is logged in”). The reviewer’s point was correct: in a directory full of “Block Visibility,” “Conditional Blocks,” and “Block Conditions” plugins, “Block When” doesn’t help users distinguish anything.

The renaming was mechanical but extensive. PHP namespace from Block_When\ to RenderWhen\, constants from BLOCK_WHEN_* to RENDERWHEN_*, filter prefixes, CSS class names, the bootstrap filename, the text domain, the block attribute key. 48 files changed in one commit. The lesson isn’t about the rename itself — it’s that a plugin’s name is part of its architecture. Naming things well costs less than renaming them later, even when “later” is one review cycle later.

Convention drift

A bug that almost shipped because I caught it in smoke testing rather than in any test suite: my three built-in conditions had inconsistent IDs. User_State_Condition::get_id() returned 'user-state' (kebab-case). Date_Range_Condition::get_id() returned 'date_range' (snake_case). Device_Condition::get_id() returned 'device'.

The bug only surfaced when the editor saved a conditionId in one casing and the renderer’s registry lookup failed to find it in the other. The temptation in that moment was to add a tolerance layer to the renderer — try both casings, alias them, paper over the inconsistency. I almost did. Then I caught myself: that would lock in the inconsistency as intended behavior, codify it via tests, and make removal impossible.

The right fix was to enforce a convention. I picked snake_case (matches PHP variable conventions, matches the filter naming), renamed get_id() in the user-state class, and added a registry-level test that asserts every built-in condition’s ID matches [a-z][a-z0-9_]*. The convention is now enforced at the layer where conventions live.

The general lesson: when producer and consumer disagree on a format, fix the producer. Don’t make the consumer tolerant. Tolerance is a one-way ratchet.

What I’d do differently

If I were starting over:

Test on a fresh install much earlier. The Composer autoloader bug would have surfaced on day one if I’d installed the plugin via “Upload Plugin” instead of running it from a dev directory. I caught it three days before submission.

Spend less time on the banner. Plugin banners matter less than plugin icons. The icon shows up in search results; the banner shows up only after someone has already clicked through. I spent ~90 minutes on the banner and ~20 on the icon. The ratio should have been reversed.

Set up the GitHub Actions deploy from day one. Manual SVN commits for the first release worked. For every release after that, the 10up/action-wordpress-plugin-deploy action is the right answer. Tag in git, push, action handles the rest. I set this up after launch; doing it before would have saved one full mental context-switch.

Where to find it

The plugin is at wordpress.org/plugins/renderwhen. The source is on GitHub — issues and PRs welcome, especially for the v1.1 features I have queued: AND/OR condition groups, URL parameter conditions, and editor-side date preview.

If you build something with the Conditions API, I’d genuinely love to hear about it. It’s the part of the plugin I’m most curious to see used in the wild.

Leave a Reply