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 four invariants
Section titled “The four invariants”The architecture-checks gate enforces these on every Pro commit. Your addon should aim for the same:
A1 - Lockstep version
Section titled “A1 - Lockstep version”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;}A2 - Dependency guard
Section titled “A2 - Dependency guard”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.
A4 - No source modification
Section titled “A4 - No source modification”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.
How Pro consumes Free’s hooks
Section titled “How Pro consumes Free’s hooks”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.phpadd_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.”
How Pro extends Free’s blocks
Section titled “How Pro extends Free’s blocks”Free’s blocks render server-side. Pro extends them via two mechanisms:
1 - Form field injection
Section titled “1 - Form field injection”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.
2 - REST response filtering
Section titled “2 - REST response filtering”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.
How Pro adds new database tables
Section titled “How Pro adds new database tables”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.
How Pro extends the credit-purchase flow
Section titled “How Pro extends the credit-purchase flow”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.
Pre-commit + pre-push checks
Section titled “Pre-commit + pre-push checks”bin/architecture-checks.sh runs every gate (U1..U6, A1, A2, A3)
on every push. If you’re authoring against Pro:
composer arch-checks # Run the gate manually anytimecomposer 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.
When the contract doesn’t fit
Section titled “When the contract doesn’t fit”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:
- Open a PR against Free to add the missing extension point, or
- Build the feature inside Pro using a different mechanism, or
- 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.

