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
The architecture-checks gate enforces these on every Pro commit.
Your addon should aim for the same:
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.1.1'), 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
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
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
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
The cleanest examples in the codebase:
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' );
Same pattern for wcb_pro_licensed, wcb_pro_version,
wcb_pro_ai_enabled, wcb_pro_alerts_enabled,
wcb_pro_resumes_enabled. Each returns a value Pro alone can
authoritatively answer.
Bridging Free's credit filters to the SDK
Free fires wcb_employer_credit_balance as a 0-default filter.
Pro intercepts and returns the real balance from the credit SDK:
add_filter( 'wcb_employer_credit_balance', function ( $balance, $user_id ) {
return \Wbcom\Credits\Credits::get_balance( 'wp-career-board', $user_id );
}, 10, 2 );
Same approach for wcb_credit_purchase_url, wcb_board_credit_cost,
wcb_credit_low_threshold. Free has the placeholder; Pro fills it.
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
Free's blocks render server-side. Pro extends them via two
mechanisms:
1 - Server-side action injection
Free's job-form fires wcb_job_form_step1_fields,
wcb_job_form_step2_fields, etc. at predictable points. Pro
injects extra inputs:
add_action( 'wcb_job_form_step3_fields', function () {
// Render Pro-only AI description toggle.
});
2 - REST response filtering
Every REST response goes through a wcb_rest_prepare_* filter.
Pro adds Pro-specific fields:
add_filter( 'wcb_rest_prepare_application', function ( $row, $app, $request, $context ) {
$row['kanban_stage'] = wcbp_get_application_stage( $app->ID );
return $row;
}, 10, 4 );
These two patterns cover ~90% 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
Pro owns 8 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).
All creation goes through dbDelta() in core/class-pro-install.php:
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
The Wbcom Credits SDK supports adapter classes. Pro registers each
e-commerce plugin's adapter:
\Wbcom\Credits\Adapters\WooCommerce::register( 'wp-career-board' );
\Wbcom\Credits\Adapters\PMPro::register( 'wp-career-board' );
\Wbcom\Credits\Adapters\MemberPress::register( 'wp-career-board' );
\Wbcom\Credits\Adapters\WooSubscriptions::register( 'wp-career-board' );
Each adapter listens for that plugin's "order completed" event and
writes a topup row to the ledger. To add support for a new
e-commerce plugin, write a new adapter class (one method:
register that hooks the relevant action). See
04-credits-sdk.md.
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 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.
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.