The WordPress.org freemium trap: how to ship a Pro plugin without getting suspended
Current Situation Analysis
Developing a freemium WordPress plugin requires navigating a strict distribution constraint that catches many engineering teams off guard. The WordPress.org plugin repository explicitly prohibits trialware: any functionality that is present in the codebase but hidden, disabled, or gated behind a license check, conditional flag, or UI overlay. The guideline is unambiguous—code cannot be contained in the free distribution, even if it never executes.
This rule is frequently misunderstood because traditional SaaS architecture relies on feature flags, license validation middleware, and conditional rendering. Developers naturally assume that wrapping premium logic in if ( is_pro() ) or greying out UI elements with upgrade tooltips satisfies compliance. It does not. The WordPress.org review team scans the actual ZIP payload. If dormant premium code exists, the plugin faces immediate suspension. Suspension is not a temporary penalty; it permanently severs organic discovery, which is the primary acquisition channel for free-tier plugins.
The tension arises from a mismatch between development workflow and distribution reality. Most teams build a single monolithic codebase and toggle features at runtime. WordPress.org requires two distinct artifacts from the same source. Bridging this gap without duplicating codebases or violating compliance demands a deliberate architectural shift: treating the free plugin as an extensible foundation rather than a locked-down product.
WOW Moment: Key Findings
The compliance constraint forces a structural decision that ultimately improves code quality. By removing gated code from the free distribution, you eliminate conditional branching, reduce coupling, and align with WordPress core design patterns. The following comparison illustrates the operational impact of adopting a hook-bridge architecture versus traditional gated patterns.
| Approach | WP.org Compliance | Code Coupling | Build Complexity | Feature Isolation |
|---|---|---|---|---|
| Traditional Gated (License Checks) | ❌ Fails (Trialware) | High (Core + Pro mixed) | Low (Single build) | Poor (Shared state) |
| Hook-Bridge Architecture | ✅ Passes (Zero dormant code) | Low (Core defines, Pro extends) | Medium (Branching build) | High (Independent files) |
This finding matters because it transforms a compliance hurdle into an architectural advantage. The hook-bridge pattern enforces separation of concerns by design. Premium features become isolated modules that register against stable extension points. The free plugin remains lightweight, auditable, and fully functional without conditional overhead. More importantly, it creates a sustainable scaling model: adding a new premium capability requires only a new callback file and a build configuration update, never a modification to the core distribution logic.
Core Solution
The hook-bridge architecture replaces runtime feature gating with compile-time distribution branching. The free plugin defines stable extension points using WordPress's native hook system. The premium plugin registers callbacks against those points. A build script produces two distinct ZIP files from a single repository, ensuring the free artifact contains zero premium logic.
Step 1: Define Extension Points in the Core Plugin
Identify every location where premium behavior could logically attach. Replace conditional checks with apply_filters() for data transformation and do_action() for side effects. These hooks must exist in the core plugin and ship in both distributions.
// src/Core/Importer.php
namespace WDS\Core;
class ProductImporter {
public function run_import( array $source_data ): void {
// Core import logic executes first
$post_id = $this->create_post( $source_data );
// Extension point: Allow premium modules to modify query fields
$extra_fields = apply_filters( 'wds_graphql_query_fields', '' );
$this->fetch_extended_data( $source_data['id'], $extra_fields );
// Extension point: Trigger premium post-import tasks
do_action( 'wds_after_core_import', $post_id, $source_data );
}
}
Rationale: Hooks are evaluated at runtime. If no callbacks are registered, apply_filters() returns the default value and do_action() executes silently. This guarantees zero premium code runs in the free distribution without requiring conditional checks.
Step 2: Implement the License Stub Pattern
The core plugin often needs to reference licensing state for UI rendering or upgrade prompts. The free distribution cannot contain validation logic or external API calls. Instead, swap a full license manager with a stub that returns safe, predictable defaults.
// src/Licensing/LicenseStub.php (Ships in free build)
namespace WDS\Licensing;
class LicenseStub {
public static function get_tier(): string {
return 'free';
}
public static function get_key(): string {
return '';
}
public static function is_valid(): bool {
return false;
}
public static function get_upgrade_url(): string {
return 'https://example.com/upgrade';
}
}
// src/Licensing/LicenseManager.php (Ships in pro build)
namespace WDS\Licensing;
class LicenseManager {
// Contains API validation, expiration checks, and tier resolution
// Identical public interface to LicenseStub
}
Rationale: Identical method signatures allow the core plugin to call LicenseManager::get_upgrade_url() without knowing which implementation is loaded. The build script swaps the file, ensuring the free ZIP never contains network calls or validation logic.
Step 3: Register Premium Callbacks
Premium features live in isolated files that only exist in the premium distribution. Each file registers callbacks against the core extension points.
// src/Premium/ImageSync.php (Absent from free ZIP)
namespace WDS\Premium;
class ImageSync {
public function __construct() {
add_filter( 'wds_graphql_query_fields', [ $this, 'append_image_fields' ] );
add_action( 'wds_after_core_import', [ $this, 'process_images' ], 10, 2 );
}
public function append_image_fields( string $fields ): string {
return $fields . "\nmedia { sourceUrl altText }";
}
public function process_images( int $post_id, array $data ): void {
if ( empty( $data['media'] ) ) return;
// Download, sanitize, and attach media to post
}
}
Rationale: Callbacks are registered during plugin initialization. Since the file is physically absent from the free distribution, the hooks remain unregistered. The architecture guarantees compliance by omission, not by restriction.
Step 4: Branch the Build Process
A single repository must produce two distinct artifacts. The build script stages the plugin, conditionally includes premium directories, and swaps licensing files before packaging.
#!/usr/bin/env bash
# build.sh
STAGE_DIR="build/stage"
rm -rf "$STAGE_DIR"
mkdir -p "$STAGE_DIR"
# Copy core files
cp -r src/Core/* "$STAGE_DIR/"
cp -r src/Assets/* "$STAGE_DIR/assets/"
if [[ "$1" == "--free" ]]; then
echo "Building free distribution..."
cp src/Licensing/LicenseStub.php "$STAGE_DIR/license.php"
# Ensure no premium files leak
find "$STAGE_DIR" -type f -name "*premium*" -delete
else
echo "Building premium distribution..."
cp -r src/Premium/* "$STAGE_DIR/premium/"
cp src/Licensing/LicenseManager.php "$STAGE_DIR/license.php"
fi
# Package ZIP
zip -r "wds-plugin-${1:-pro}.zip" "$STAGE_DIR"
Rationale: Build-time branching eliminates runtime complexity. The free ZIP is guaranteed clean. The premium ZIP contains all extension modules. CI/CD pipelines can automate this process, tagging releases and pushing to WordPress.org SVN or private distribution endpoints accordingly.
Pitfall Guide
1. Dormant Code Leakage
Explanation: Developers accidentally commit premium logic inside core files, wrapped in if ( defined( 'WDS_PRO' ) ) or similar checks. The code is present in the ZIP, violating trialware rules.
Fix: Enforce directory-level separation. Core files must never import or reference premium namespaces. Use static analysis tools to scan the staging directory for prohibited keywords before packaging.
2. Inconsistent Stub Interfaces
Explanation: The license stub and full manager diverge in method signatures or return types. The core plugin crashes when calling a method that exists in one but not the other.
Fix: Define a PHP interface (LicenseInterface) that both classes implement. Use IDE type-hinting and PHPUnit tests to verify signature parity across distributions.
3. Hook Priority Collisions
Explanation: Multiple premium modules register callbacks on the same hook without specifying priority. Execution order becomes unpredictable, causing data overwrites or incomplete processing.
Fix: Document expected priority ranges in core hook definitions. Use add_action( 'hook', $callback, 10, $args ) consistently. Reserve priorities 1-9 for core, 10-20 for standard premium, and 21+ for override modules.
4. Hardcoded Plugin Paths in Extensions
Explanation: Premium files reference the main plugin file path directly for deactivation or activation hooks. When loaded from a subdirectory, WordPress fails to resolve the path.
Fix: Define a constant in the main plugin file (define( 'WDS_MAIN_FILE', __FILE__ );). Premium modules reference the constant instead of guessing paths.
5. Over-Engineering the Build Script
Explanation: Teams add complex conditional logic, environment variables, and manual steps to the build process. This introduces human error and breaks CI/CD automation.
Fix: Keep the build script declarative. Use a single flag (--free or --pro). Automate staging, file swapping, and ZIP creation. Validate the output with a post-build compliance checker that scans for premium namespaces.
6. UI Logic Coupled to Business Logic
Explanation: Premium callbacks render HTML or manage admin notices directly inside do_action handlers. This mixes concerns and makes testing difficult.
Fix: Separate data processing from presentation. Callbacks should return structured data or trigger state changes. Use WordPress admin hooks (admin_menu, admin_notices) for UI rendering, keeping business logic pure.
7. Ignoring WordPress.org Review Feedback
Explanation: Teams assume automated compliance checks replace human review. The WP.org team may flag subtle trialware patterns, such as upgrade prompts that imply locked functionality. Fix: Treat the review process as a compliance audit. Ensure upgrade messaging focuses on added value, not missing features. Avoid phrases like "unlock this" or "pro version required." Use "enhance your workflow" or "access advanced tools."
Production Bundle
Action Checklist
- Audit core plugin for conditional gates: Remove all
if ( is_pro() )or license checks from core files. - Map extension points: Identify every data transformation and side effect location where premium logic could attach.
- Implement hook definitions: Replace conditional logic with
apply_filters()anddo_action()in core classes. - Create license stub: Build a stub class matching the full manager's interface, returning safe defaults.
- Isolate premium modules: Move all premium logic to dedicated files that only register callbacks.
- Configure build branching: Set up a script that stages core files, conditionally includes premium directories, and swaps licensing files.
- Add compliance validation: Run a pre-package scan to verify zero premium namespaces exist in the free ZIP.
- Document hook contracts: Publish priority ranges, parameter expectations, and return types for each extension point.
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Solo developer, simple pro features | Hook-Bridge + Bash build script | Low overhead, fast iteration, easy to maintain | Minimal (dev time only) |
| Agency, multiple premium tiers | Hook-Bridge + Composer autoloader + CI pipeline | Scalable, enforces interface contracts, automates distribution | Moderate (CI setup, tooling) |
| Enterprise, complex licensing & SaaS sync | Hook-Bridge + Microservice license validator | Decouples validation from plugin, supports high availability | High (infrastructure, security review) |
| Plugin requires runtime feature toggles | Avoid hook-bridge; use WP.org-compliant settings page | WP.org forbids hidden features; settings pages are transparent | Low (UI/UX development) |
Configuration Template
// core/ExtensionPoints.php
namespace WDS\Core;
class ExtensionPoints {
public static function init(): void {
// Data transformation hook
add_filter( 'wds_query_fields', function( string $fields ): string {
return $fields; // Default: no extra fields
}, 10, 1 );
// Post-processing hook
add_action( 'wds_after_import', function( int $post_id, array $data ): void {
// Default: no action
}, 10, 2 );
}
}
// premium/ImageProcessor.php
namespace WDS\Premium;
class ImageProcessor {
public function __construct() {
add_filter( 'wds_query_fields', [ $this, 'add_media_fields' ], 10, 1 );
add_action( 'wds_after_import', [ $this, 'sync_media' ], 10, 2 );
}
public function add_media_fields( string $fields ): string {
return $fields . "\nmedia { sourceUrl }";
}
public function sync_media( int $post_id, array $data ): void {
// Implementation
}
}
Quick Start Guide
- Initialize the repository: Create
src/Core/,src/Premium/, andsrc/Licensing/directories. Define your main plugin file with a constant for the main path. - Define extension points: Replace all conditional logic in core classes with
apply_filters()anddo_action(). Document parameter signatures. - Build the stub: Create
LicenseStub.phpwith identical methods to your future license manager. Return'free','',false, and a static upgrade URL. - Configure the build script: Write a bash or Node script that copies core files, conditionally includes
src/Premium/, swaps the licensing file, and packages the ZIP. Test both--freeand--prooutputs. - Validate compliance: Unzip the free build and grep for premium namespaces, license validation logic, or conditional gates. If any exist, adjust the build pipeline before submitting to WordPress.org.
Mid-Year Sale — Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register — Start Free Trial7-day free trial · Cancel anytime · 30-day money-back
