Skip to content

Extending Free - The Canonical Pro Contract

Pro is a worked example of “how to extend WP Career Board Free without forking.” Every pattern here is something a third-party addon can copy verbatim.

The architecture-checks gate enforces these on every Pro commit. Your addon should aim for the same:

Pro’s WCBP_VERSION constant matches Free’s WCB_VERSION at every commit. The pre-commit hook checks both files and fails the build on drift. Why: shipping one updated and the other not means a customer has a half-built release; cross-plugin hook signatures go out of sync.

For an addon, your equivalent is “what’s the minimum Free version I work against?” Declare it as a constant (MYADDON_MIN_WCB = '1.4.3'), check at boot:

if ( ! defined( 'WCB_VERSION' )
|| version_compare( WCB_VERSION, MYADDON_MIN_WCB, '<' ) ) {
add_action( 'admin_notices', 'myaddon_min_wcb_notice' );
return;
}

Pro defines wcbp_free_active() and uses it inside the boot path:

function wcbp_free_active(): bool {
return defined( 'WCB_VERSION' );
}
if ( ! wcbp_free_active() ) {
add_action( 'admin_notices', 'wcbp_missing_free_notice' );
return;
}

The guard runs on plugins_loaded@20 - Free uses default priority 10, so by the time Pro’s check fires Free has already booted. If a customer deactivates Free via WP-CLI (which bypasses the Requires Plugins: header), Pro detects it and gracefully skips its hooks instead of fataling.

A3 - REST namespace shared, paths disjoint

Section titled “A3 - REST namespace shared, paths disjoint”

Both plugins register under the same wcb/v1 namespace - that’s intentional so the API surface stays cohesive to consumers. Disjointness is what matters:

Free: /jobs, /jobs/{id}, /applications/{id}, /candidates/{id} ...
Pro: /resumes, /boards/{id}, /pipeline, /alerts ...

The architecture-checks gate (Pro’s check_A3) reads both manifests’ .rest.endpoints[].route and fails if any path appears in both. If you’re adding routes from an addon, pick a unique sub-path and document it.

Pro never patches Free’s classes, never calls function_alias, never monkey-patches. All extension goes through documented filters and actions. The contract is one-way: Free exposes the hooks; Pro and other addons consume them.

The cleanest examples in the codebase:

Returning Pro’s status to Free’s gate filters

Section titled “Returning Pro’s status to Free’s gate filters”

Free fires apply_filters( 'wcb_pro_active', false ) to check whether Pro is running. Pro registers:

// In core/class-free-coordination.php
add_filter( 'wcb_pro_active', '__return_true' );

Pro registers all of these in core/class-free-coordination.php: wcb_pro_active, wcb_pro_licensed, wcb_pro_version, wcb_pro_ai_enabled, wcb_pro_alerts_enabled, wcb_pro_resumes_enabled, and wcb_pro_settings_saved_notice. Each returns a value Pro alone can authoritatively answer.

Reading credit balances and pricing from the SDK

Section titled “Reading credit balances and pricing from the SDK”

The credit system is owned by the Wbcom Credits SDK, not by a Free placeholder filter. Pro’s blocks and endpoints read the balance directly:

$balance = \Wbcom\Credits\Credits::get_balance( 'wp-career-board', $user_id );
$url = \Wbcom\Credits\Credits::get_purchase_url( 'wp-career-board' );

The one extension point Pro exposes for pricing is the wcbp_consumer_cost filter, applied inside each consumer’s cost callback when Pro registers with the SDK:

// Args: ( int $base_cost, int $user_id, int $item_id, int $board_id, string $consumer_slug )
add_filter( 'wcbp_consumer_cost', function ( $cost, $user_id, $item_id, $board_id, $consumer ) {
if ( 'job_post' === $consumer && current_user_can( 'wcb_employer_pro_tier' ) ) {
return max( 0, (int) ( $cost / 2 ) );
}
return $cost;
}, 10, 5 );

See 04-credits-sdk.md for how Pro registers its consumers, adapters, and gateways with the SDK.

Hooking the board picker to filter by group membership

Section titled “Hooking the board picker to filter by group membership”

Free’s job-form template fires apply_filters( 'wcb_board_options_for_employer', $options, $user_id ). Pro’s BP-groups integration consumes it to drop boards whose linked BuddyPress group the employer is not a member of:

add_filter( 'wcb_board_options_for_employer',
array( BpGroupBoards::class, 'restrict_boards_to_user_groups' ),
10, 2
);

This is the canonical pattern for “Pro adds a constraint to a Free control surface.”

Free’s blocks render server-side. Pro extends them via two mechanisms:

Free’s forms expose declarative field-schema filters that Pro’s field builder hooks to inject custom field groups: wcb_job_form_fields, wcb_company_form_fields, wcb_candidate_form_fields, and wcb_resume_form_fields. Each passes the current field array plus a context id (board id, or resume id):

add_filter( 'wcb_job_form_fields', function ( array $fields, int $board_id ) {
$fields['my_group'] = array( /* field definitions */ );
return $fields;
}, 10, 2 );

Pro persists the submitted values on the wcb_job_created / wcb_job_updated actions.

REST responses go through wcb_rest_prepare_* filters. Pro adds Pro-specific fields to the board, board-stage, resume, and notification responses (wcb_rest_prepare_board, wcb_rest_prepare_board_stage, wcb_rest_prepare_resume, wcb_rest_prepare_notification):

add_filter( 'wcb_rest_prepare_resume', function ( $row, $resume, $request, $context ) {
$row['my_extra_field'] = get_post_meta( $resume->ID, '_my_extra', true );
return $row;
}, 10, 4 );

These two patterns cover most of Pro’s UI extensions. Anything they can’t handle is a real gap in Free’s hook surface - file a Free PR to add the hook, then consume it from Pro.

Pro owns 9 tables (wcb_credit_ledger, wcb_field_groups, wcb_field_definitions, wcb_field_values, wcb_job_boards, wcb_job_alerts, wcb_application_stages, wcb_ai_vectors, wcb_notifications). All creation goes through dbDelta() in core/class-pro-install.php (the wcb_credit_ledger table is created by the Credits SDK’s Ledger::maybe_create_table('wcb'), which Pro does not duplicate):

private static function create_field_groups_table( $wpdb ): void {
$table_name = $wpdb->prefix . 'wcb_field_groups';
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table_name} ( ... ) {$charset};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
}

The pattern (one private method per table) makes the schema greppable. Schema version is tracked in wcbp_db_version option.

Pro registers everything (slug, consumers, and settings) with the Wbcom Credits SDK through its wbcom_credits_sdk_registry action in wp-career-board-pro.php:

add_action( 'wbcom_credits_sdk_registry', function ( \Wbcom\Credits\Registry $registry ) {
$registry->register( array(
'slug' => 'wp-career-board',
'prefix' => 'wcb',
'version' => WCBP_VERSION,
'file' => WCBP_FILE,
'user_type' => 'employer',
'consumers' => array( /* job_post, featured_upgrade */ ),
'settings' => array( /* low_threshold, purchase_url, admin_settings_hook */ ),
) );
} );

The SDK ships the e-commerce adapters (WooCommerce, WC Subscriptions, WC Memberships, PMPro, MemberPress); each adapter listens for that plugin’s “order completed” event and writes a topup row to the ledger. Adapters self-discover when the host plugin is active - Pro does not register them one by one. To add support for a new e-commerce plugin, write a new adapter class that implements AdapterInterface. See 04-credits-sdk.md.

bin/architecture-checks.sh runs every gate (U1..U6, A1, A2, A3) on every push. If you’re authoring against Pro:

Terminal window
composer arch-checks # Run the gate manually anytime
composer ci # Run the full pipeline (PHPStan, PHPCS, arch, journeys)

The pre-push git hook (one-time composer install-hooks activates it) runs composer ci:no-journeys before every push. Bypass for emergencies only: SKIP_LOCAL_CI=1 git push.

If you find yourself wanting to do something the four invariants don’t allow (e.g. modify Free source, register a colliding REST path), STOP and either:

  1. Open a PR against Free to add the missing extension point, or
  2. Build the feature inside Pro using a different mechanism, or
  3. Talk to the team - there’s usually a third option we’d rather ship than break the contract.

The contract exists because we’ve shipped a paired plugin set for years; the four invariants are the things that broke when we tried to “just patch it this once.” They’re not bureaucracy - they’re scar tissue.