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.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
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.
Pro REST route reference
Pro registers 35 routes under the shared wcb/v1 namespace (all
extend WCB\Pro\Api\Pro_REST_Controller, one Endpoint class per
group in api/endpoints/). License status never gates these -
license drives automatic updates only (see
01-overview.md).
| Group |
Routes |
Controller |
| Notifications bell |
GET /notifications · PUT /notifications/{id}/read · PUT /notifications/read-all · DELETE /notifications/{id} |
NotificationsBellEndpoint |
| Job alerts |
GET,POST /alerts · PUT,DELETE /alerts/{id} |
AlertsEndpoint |
| AI hiring tools |
POST /ai/match · GET /ai/ranked-applications/{job_id} · POST /jobs/{job_id}/ai-cover-letter · POST /jobs/ai-description · GET /candidates/{id}/matches |
AiEndpoint (see 05-ai-providers.md) |
| Boards + pipeline stages |
POST,PUT,PATCH,DELETE /boards/{id}/stages/{stage_id} · GET /boards/{id} · GET,POST /boards/{id}/stages |
BoardsProEndpoint |
| Application pipeline (Kanban) |
PUT /applications/{id}/stage · GET /jobs/{id}/kanban |
PipelineEndpoint |
| Resumes |
GET /resumes · GET,POST /candidates/{id}/resumes · GET,PUT,DELETE /resumes/{id} · GET /resumes/{id}/pdf · POST /resumes/{id}/bookmark · POST /resumes/photo-upload |
ResumeEndpoint |
| Field builder |
GET,POST /fields/groups · POST,PUT,PATCH,DELETE /fields/groups/{id} · GET,POST /fields/groups/{group_id}/fields · POST,PUT,PATCH,DELETE /fields/{id} · POST /fields/reorder |
FieldsEndpoint |
| Credits |
GET /employers/{id}/credits - balance + last 50 ledger rows. Own balance, or wcb/manage-credits ability for any employer. |
CreditsEndpoint |
| Analytics |
GET /analytics/credits.csv - CSV download of the credit ledger, gated on the wcb/manage-credits ability |
AnalyticsEndpoint |
| Geocoding |
GET /geocode |
GeocodeEndpoint |
| Native push (mobile/companion app, 1.7.0) |
POST /push/register-device · DELETE /push/register-device |
PushEndpoint |
| Setup wizard (admin only) |
POST /wizard/activate-license · POST /wizard/setup-credits · POST /wizard/create-pro-pages |
ProSetupWizard (the one documented carve-out that calls register_rest_route() directly - see docs/HOOKS.md for the rationale) |
GET /wcb/v1/employers/{id}/credits is the real balance route.
A /credits/balance path referenced in a Pro_REST_Controller
docblock is illustrative only and is never registered - do not
build against it.
GET /wcb/v1/resumes (the public archive) takes an optional
public author int param (default 0 = all authors), added in
1.5.1 so an external consumer - the mobile app, a BuddyNext
profile tab - can fetch one member's public resumes through the
same visibility rules the archive already applies, without a new
route.
Mobile/companion-app push routes (1.7.0)
POST and DELETE /wcb/v1/push/register-device back the native
(Expo) push feature for the mobile app. Both require a logged-in
member (is_logged_in()) and are not wrapped in pro_check() -
like the notification bell, a member's own device registration is a
delivery surface that must keep working regardless of license
status.
// POST /wcb/v1/push/register-device
// Body: { "expo_push_token": "ExponentPushToken[xxxxxxxx]", "platform": "ios"|"android", "device_name": "..." }
// -> 201 { "registered": true } | 400 wcb_invalid_push_token | 500 wcb_push_register_failed
// DELETE /wcb/v1/push/register-device
// Body: { "expo_push_token": "ExponentPushToken[xxxxxxxx]" }
// -> 200 { "unregistered": true }
The token is validated against Expo's ExponentPushToken[...] /
ExpoPushToken[...] shape before it's stored. PushModule does not
author its own notifications or keep a separate queue - it listens
on the existing wcb_notification_created action and fans each
message out to the caller's registered devices via the shared
AsyncScheduler. See
03-hooks-reference.md.
The SDK's own checkout/webhook/refund routes (a separate
wbcom-credits/v1 namespace, not wcb/v1) are documented in
04-credits-sdk.md.
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' );
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
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
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:
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
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
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
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
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.