Skip to main content

Command Palette

Search for a command to run...

Making a freemium WordPress plugin pass the "no license gating" rule

How I restructured a WooCommerce plugin so the free build physically doesn't contain PRO code and what the Freemius build tool did to my files along the way

Updated
5 min readView as Markdown
Making a freemium WordPress plugin pass the "no license gating" rule

When you submit a plugin to WordPress.org, the form asks you to confirm it contains no "artificial restrictions" or "license-gated functionality". I ticked that box in good conscience for version 1.0.0 and then, reading the guideline again, realised I couldn't.

This is how I fixed it. The plugin is a WooCommerce quantity-rules + tiered-pricing plugin, freemium, with PRO handled by Freemius. Currently awaiting review, so treat this as "what I believe is correct", not "what got approved".

What I had

One codebase. Every PRO feature — role-based rules, category rules, decimal quantities, tiered pricing tables — was fully present in the free build and switched off with a helper:

function qmtp_is_pro(): bool {
    return qmtp_fs()->can_use_premium_code__premium_only();
}

// in the rule resolver
if ( qmtp_is_pro() ) {
    $rule = $this->apply_role_rules( $rule, $user );
}

The settings page rendered the PRO sections with disabled inputs and a lock icon. From a SaaS mindset this is normal. From the directory's point of view it's exactly what they don't allow: the user downloaded code they aren't permitted to run.

The target

The free build must be generated without the PRO code. Not hidden, not disabled — absent. Freemius supports this with two mechanisms:

  1. @fs_premium_only in a file's docblock → the whole file is removed from the free build.

  2. Any method or function whose name ends in __premium_only → removed from the free build, along with if ( ...->is__premium_only() ) { ... } blocks that call it.

So the job was: move every line of PRO behaviour behind one of those two.

The restructure

Whole files. Tiered pricing, tier sets, cart-level rules, bulk editor, category fields, and the tier table template were already separate classes, so those just got the docblock tag:

<?php
/**
 * Tiered pricing engine.
 *
 * @fs_premium_only
 */

Six files gone from the free build with no other change.

Mixed files. The rule resolver, product metabox, admin fields, settings page and frontend each had free and PRO logic interleaved. Pattern used everywhere:

// before
if ( qmtp_is_pro() ) {
    $rule = $this->apply_role_rules( $rule, $user );
}

// after
if ( qmtp_fs()->is__premium_only() ) {
    $rule = $this->apply_role_rules__premium_only( $rule, $user );
}

Freemius strips both the method and the if block. The free build never sees apply_role_rules.

Settings UI. Rather than rendering PRO sections in a disabled state, the free build now doesn't render them at all. Where an upsell makes sense there's a single line: "Tiered pricing and role-based rules are available in Pro. Learn more →". That is the form of upsell the guidelines explicitly allow.

What stays in free on purpose. The Rule value object still has fixed_values, decimal and unit_label properties, and validation handles them generically. In the free build nothing ever populates them — the resolver code that would is gone. That's a generic rule engine, not gating; there's no license check anywhere in that path. Same for default settings keys and message strings: data, not behaviour.

The part nobody warned me about

Freemius produces the free build by parsing and reprinting every file that contains __premium_only. The output is functionally identical but not textually identical:

  • // translators: comments inside array literals are dropped

  • trailing // phpcs:ignore ... comments are moved to the line after the statement

Result: my source passed Plugin Check clean; the generated free zip failed with 7 MissingTranslatorsComment errors and 6 nonce warnings. Nothing wrong with my code — the comments just weren't where the sniff expected them anymore.

Fixes that survive the round-trip:

// translators comment on its own line, before a standalone statement
/* translators: %s: product name */
$label = __( 'Minimum order for %s', 'plugstack-quantity-manager-tiered-pricing' );

$fields[] = array(
    'label' => $label,
);



// phpcs:ignore on its own line BEFORE the statement, not at the end
// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
do_action( 'woocommerce_after_quantity_input_field' );

And for request variables, filter_input( INPUT_GET, 'tab', FILTER_SANITIZE_SPECIAL_CHARS ) instead of $_GET['tab'] — the nonce sniff doesn't trigger on filter_input, and it's cleaner anyway.

Verifying without uploading

Uploading to Freemius for every check is slow, so I wrote a short Python script that mimics the processor: drops @fs_premium_only files, removes __premium_only methods and their if blocks, then greps the result for any leftover __premium_only string. If anything remains, the build is wrong. Then Plugin Check runs against that output in a local Docker WordPress.

Not perfect — it doesn't reproduce the comment reshuffling — but it catches the structural mistakes, and the final check is always Plugin Check on the real Freemius-generated zip.

Rules I'm keeping

  • Free and PRO are two products that share code, not one product with a switch.

  • No is_pro() around functionality in shared code. Only around upsell text.

  • In any file with __premium_only: no end-of-line comments, no translator comments inside arrays.

  • Plugin Check runs on the generated zip. Always.

If you've shipped a freemium plugin through the directory and structured it differently, I'd like to hear how — particularly how you handle the "generic engine that PRO happens to feed" grey area.