Skip to content

The platform layer

What Club Player Manager offers other plugins in this repo, and how to depend on it.

Club Player Manager is the core plugin: it is installed and active on every site the others run on. That makes it the natural home for the admin plumbing they would otherwise each re-implement — so it ships that plumbing as a small, declared API rather than leaving each plugin to retype it.


1. What is public

Only the classes in plugins/club-management/includes/platform/.

Everything else in the plugin is internal and may be renamed, moved or rewritten without notice. That specifically includes CPM_Database, CPM_Access, CPM_Settings, CPM_Schema and the repositories under includes/db/ — a satellite must not call them, and must not query their tables directly either.

Class What it gives you
CPM_Platform The version contract, the shared admin stylesheet, the FA player report import seam, and the player and guardian lookups, the Club Mailer send-as headers, and the squad list
CPM_Platform_Admin_Handler Base for admin_post_* handlers: guards, redirects, paging, typed $_POST readers
CPM_Platform_View Badges, empty states, notices, sortable headers, pagination
CPM_Platform_Capabilities Granting a plugin's virtual capabilities via user_has_cap

2. Depending on it

CPM_Platform::VERSION is the API version, and is not the plugin version. It moves only when this contract changes: the minor when something is added, the major when anything a satellite may already call changes or goes away. Tying the check to CPM_VERSION instead would make every unrelated core release look like a breaking change.

These two numbers are load-bearing in production, and CI enforces them. Core and each satellite are deployed by separate WordPress.com connections, so for a few minutes after every release the site runs the new satellite against the previous core — see the monorepo notes in the repository. The guard below is what makes that window harmless, and it can only work if the floor is honest. No suite can tell: they all load core and satellite from one working tree. So platform-contract.yml asks on every pull request, via bin/check-platform-contract.sh and a run of each satellite's suite against the core currently on main:

  • changing anything in includes/platform/ requires CPM_Platform::VERSION to move — a method added under an unchanged version is one no satellite can ever ask for;
  • a satellite's floor must name a platform version that exists;
  • a satellite that declares Requires Plugins: club-management must declare a floor too;
  • a satellite that no longer works against the core on main must have raised its floor above that core's platform version, so its guard holds it back rather than letting it fatal;
  • every public member of includes/platform/ declares an @since;
  • and no satellite calls a member newer than its own floor.

Run the text checks yourself with composer contract.

@since is what makes a floor checkable

Every public class, method and constant in includes/platform/ carries an @since naming the platform version it first shipped in:

    /**
     * Returns a status badge. …
     *
     * @since 1.0
     *
     * @param  string $label   Text inside the badge.
     * @return string
     */
    public static function badge( string $label, string $variant = 'default' ): string {

That tag is not documentation for its own sake. It is the data the contract checks a floor against: bin/check-platform-contract.sh reads every platform symbol a satellite mentions, looks up its @since, and fails if any of them is newer than that satellite's <PREFIX>_REQUIRES_PLATFORM.

Without it a floor was an assertion nobody could verify, and it rotted exactly as you would expect. Club Mailer declared 1.0 while calling CPM_Platform_View::begin_scope(), added in 3.0 — four checks passed on a number that was two major versions wrong. It never fatalled only because the deploy window pairs a satellite with the previous core and never anything older. That is luck, not a guarantee, and it is what this check replaces.

So when you add anything to the platform: bump CPM_Platform::VERSION (which check 1 already makes you do) and tag the new member @since that same version. The check refuses an @since naming a version that does not exist yet, so the two cannot drift.

Choosing a floor is then mechanical rather than a guess: it is the highest @since among the platform members your satellite actually uses, and the check tells you what that is when you get it wrong.

What this does not cover. Only static references — CPM_Platform_View::badge and class names — are visible to a text check. The documented hooks below (cpm_squad_player_columns, cpm_squad_list_columns, the player actions, the extension points in §4) are part of the contract too, but they are fired from core internals rather than from includes/platform/, so they have nowhere to carry an @since yet; anything reached through call_user_func or a variable method name is invisible as well. For both, each satellite's suite run against the core on main remains the backstop.

A satellite declares the dependency twice — once for WordPress, once for itself.

In the plugin header, so WordPress enforces activation order:

 * Requires Plugins: club-management

That is WordPress 6.5+. Note it only gets you so far: these plugins are self-hosted, so WordPress cannot offer to install the missing dependency the way it can for a plugin on WordPress.org. The runtime guard below is still required.

At runtime, on plugins_loaded at a priority later than core's:

add_action( 'plugins_loaded', function () {
    if ( ! class_exists( 'CPM_Platform' ) || ! CPM_Platform::at_least( '1.0' ) ) {
        add_action( 'admin_notices', 'efa_missing_core_notice' );
        return;   // register nothing — never fatal
    }

    EFA_Access::init();
    // ... the rest of the plugin's bootstrap
}, 10 );

Core registers its own hooks on plugins_loaded priority 0 and satellites bootstrap at 10, but the platform classes are required at core's file scope, so they exist as soon as its main file has been included — whatever order the two plugins load in. That is what makes the pattern below work.

Failing closed with an admin notice matters more than it sounds: a satellite that fatals on a missing dependency takes the whole site down with it, including the admin screen the person would use to fix it.

Never require a platform subclass at file scope

Any file declaring a class that extends CPM_Platform_* must be required inside that plugins_loaded callback, not at the top of the plugin's main file.

PHP resolves a parent class when the subclass is declared, so including such a file runs the dependency check whether you wanted it to or not — and does it as a fatal, at a point where nothing can catch it.

WordPress includes every active plugin's main file during wp-settings.php, in active_plugins order, which activate_plugin() keeps sorted. Since every plugin here is prefixed club-, roughly half of them will sort before club-management — so "core is loaded by the time my file runs" is a coin toss, and file scope is far too early to be making that bet.

Put the includes in a function and call it once the check has passed:

/** Loads this plugin's classes. Deferred: several extend the platform. */
function efa_load(): void {
    require_once EFA_PLUGIN_DIR . 'includes/class-efa-access.php';
    // ... the rest
}

add_action( 'plugins_loaded', function () {
    if ( ! class_exists( 'CPM_Platform' ) || ! CPM_Platform::at_least( '1.0' ) ) {
        add_action( 'admin_notices', 'efa_missing_core_notice' );
        return;
    }
    efa_load();          // only now
    EFA_Access::init();
}, 10 );

Activation and deactivation hooks must call efa_load() themselves. They run in a request where the plugin was not yet active, so the plugins_loaded callback above did not fire for it. Every include being require_once makes the extra call free.

This is not hypothetical. English FA shipped with those requires at file scope; it was invisible while the directory was english-fa/ — which sorts after club-management/ — and took the live site down the moment it was renamed club-english-fa/. Worse, the guard above was in place the whole time and could never run, because the fatal happened first.

A suite will not catch this for you: a test bootstrap loads the platform before anything else, so every test runs in an order production never uses. PluginLoadOrderTest in plugins/club-english-fa checks it in a subprocess instead, and is worth copying for any new satellite.


3. The pieces

CPM_Platform_Admin_Handler

Subclass it and set two constants:

abstract class EFA_Admin_Handler extends CPM_Platform_Admin_Handler {
    protected const FLAG_PREFIX = 'efa';        // efa_saved / efa_error
    protected const CAPABILITY  = 'efa_manage'; // what guard() requires
}

FLAG_PREFIX is what stops two plugins reading each other's redirect flags, so it must be the plugin's own and must match what its pages pass to CPM_Platform_View::request_notice(). CAPABILITY defaults to manage_options — the strictest thing WordPress offers — so a subclass that forgets to set it fails closed rather than open.

You get guard(), page_url(), redirect_to() / redirect_saved() / redirect_error() / redirect_notice(), list_args() / total_pages() / paginate(), and the typed readers post_int(), post_text(), post_date(), post_textarea(), post_email(), post_key(), post_url(), post_bool(), post_ids(), post_text_list().

Platform 1.1 adds current_url(), with_back() and back_url(), for a detail page whose "← Back to …" link should return the user to wherever they actually came from. Link into the page with with_back() instead of page_url() — it stamps the current request onto the URL as a back arg — and resolve it on the page with back_url( $fallback ), which honours that arg only when it points at this site's own admin.php and falls back to the hardcoded target otherwise. A satellite wanting them must declare a floor of 1.1; on 1.0 they do not exist.

Use redirect_saved() / redirect_error() for outcomes a flag can carry, and redirect_notice() for the ones it cannot — "Fixtures: 12 added, 3 updated, 1 removed". The latter parks the wording in a short-lived, user-scoped transient rather than the URL, so a long summary neither makes an ugly address bar nor survives being bookmarked.

CPM_Platform_View

Static, call from templates. begin_scope() / end_scope(), badge(), badge_group(), stat_card(), stat_grid(), confirmation_split(), dash(), request_notice(), stash_notice(), render_notice(), render_empty(), back_link(), required_mark(), sort_link(), render_pagination().

Platform 3.1 added stat_card() / stat_grid() — the figure-and-label tile at the top of a screen that counts something. They own the figure's size and the label's style for the same reason badge() owns a badge's colour: the tile had been copy-pasted onto six screens with no helper, and reached four different figure sizes, so the same kind of number was a different size on adjacent screens. Both return escaped markup — echo them directly. See ADMIN-UI.md §0.1.

request_notice( $prefix ) is the read side of both redirect styles: it returns a parked notice if there is one — consuming it, so a refresh does not replay it — and otherwise falls back to the <prefix>_saved / <prefix>_error query flags. Both channels are keyed by plugin prefix and by user, so two plugins never read each other's outcome and two admins working at once never read each other's.

This is ADMIN-UI.md made executable, and the main reason the platform exists. Those conventions are worth exactly as much as their consistency, and they stay consistent when there is one implementation rather than one per plugin.

Every helper here renders plain Tailwind utility classes plus this repo's own small @utility vocabulary (see assets/css/framework/README.md), compiled scoped under .cpm-fb-scope. Wrap your admin page body in begin_scope()/end_scope() — inside its existing .wrap, not replacing it — before calling anything else here; nothing below renders correctly outside that scope, because the compiled CSS matches nothing else.

Platform 3.0 removed the dual-mode legacy .cpm-* output every helper here used to fall back to outside the scope — the whole admin, in every plugin, has moved onto Tailwind, so there is no "unmigrated" case left to fall back for. If you need a badge, dash, or similar outside this scope — a plugin's public-facing shortcode output, say — it must not call these helpers: build markup of your own that does not depend on CPM_Platform_View at all (see EFA_View::frontend_badge() / frontend_dash() / frontend_outcome_badge() / frontend_kickoff() in club-english-fa for the pattern). A satellite declaring a floor below 3.0 may still call badge()/dash()/etc. outside the scope; one declaring 3.0 or above must not.

Call CPM_Platform::enqueue_admin_styles() on your admin screens to get the classes it emits.

CPM_Platform_Capabilities

Subclass it and implement grants():

class EFA_Access extends CPM_Platform_Capabilities {
    protected static function grants( \WP_User $user ): array {
        return array(
            'efa_manage' => user_can( $user, 'manage_options' )
                || user_can( $user, 'cpm_manage_all' ),
        );
    }
}

Call EFA_Access::init() once on plugins_loaded. The base owns the three things that are easy to get subtly wrong: keying off the $user argument rather than the current user, only ever adding capability keys, and guarding re-entrancy.

The re-entrancy guard is keyed by class, and that is load-bearing. grants() implementations resolve by calling user_can(), which re-fires the same filter. With a single shared flag, one plugin resolving would suppress every other plugin's — so the example above would ask about cpm_manage_all while Club Player Manager sat behind the guard, be told "no", and lock a club admin out. tests/Unit/Platform/CapabilitiesTest.php pins this down; mutate the guard to a shared flag and that test fails.


4. Reading core's data

Never query core's tables and never call its internal classes. A satellite that reaches into wp_cpm_player_team_registrations, or calls CPM_Database::…, breaks silently the first time core refactors — and it will refactor, because those are explicitly internal.

Cross-plugin data access is a published seam: a method on a platform class, or a filter. Core has few of these today, so most are yet to be added. Add one deliberately, per caller, and document it here — that is cheaper than the alternative, which is discovering the coupling from a production fault.

CPM_Platform::import_player_report( string $file_path ): array

The one seam that goes the other way — a satellite handing writes to core rather than reading from it. English FA's Import Players screen uploads an FA Club Player Report and passes the temporary file's path straight to this method; everything else (parsing the workbook, matching players by FAN, and every resulting write to the players, guardians and team registrations tables) happens inside CPM_Importer, entirely internal to this plugin. The satellite never sees a table name or a CPM_Database call — it gets back the same result shape (imported / updated / skipped / relabelled / near_miss / errors) the core admin screen used to render directly, and renders it itself.

This is deliberately a single coarse-grained call rather than exposing the row-level pieces (upsert_player(), merge_guardians(), relabel_placeholder_fan(), …) individually: those stay internal, and the platform's surface only grows by the one operation a satellite actually needs.

CPM_Platform::players_by_fan() / fan_ids_matching_player()

$players = CPM_Platform::players_by_fan( array( 'FAN-1', 'FAN-2' ) );
// 'FAN-1' => (object) { id, fan_id, first_names, surname, full_name }

$fans = CPM_Platform::fan_ids_matching_player( 'smith' );   // for a name search

The read seam for a satellite holding its own rows against a child — English FA's team registrations — that needs to show whose they are. Players stay this plugin's: a satellite asks by FA Number and gets names back, rather than joining to cpm_players.

FA Number rather than player id, because that is what the club's FA-facing data is keyed on: it comes off the FA report and survives a player row being deleted and re-imported. A FAN with no player on file is absent from the result rather than present and empty, so a caller can tell "not on file" from "not asked about" — an orphan registration is a real state, and worth showing differently.

fan_ids_matching_player() is the companion: it resolves a free-text term to the FANs to filter on, so a satellite can offer a search by player name on a screen whose own rows carry only a FAN.

CPM_Platform::guardian_emails() / players_for_guardian_email()

$emails  = CPM_Platform::guardian_emails();                              // the club's addresses
$players = CPM_Platform::players_for_guardian_email( 'jo@example.com' ); // that guardian's children

The guardian half of the same idea. A guardian is identified by their email address, not by a row id — guardians are stored one row per child, so one parent with two children is two rows and one address. players_for_guardian_email() is therefore the lookup a satellite wants when it is showing a guardian their own children, and it matches lower-cased on both sides so it behaves the same on MySQL and on the SQLite adapter the dev site runs.

guardian_emails() exists for the case where the satellite holds an opaque credential and has to work out whose it is — English FA's emailed invitation link is an HMAC over the address, so it cannot ask "whose email hashes to this?" in SQL and instead asks for the set and matches in PHP. Returns addresses only, lower-cased, de-duplicated and sorted; it is not a way to enumerate guardian records.

CPM_Platform::guardian_emails_by_fan() — writing to guardians

$by_fan  = CPM_Platform::guardian_emails_by_fan( array( 'FAN-1', 'FAN-2' ) );
$headers = CPM_Platform::send_as_headers( get_current_user_id() );
wp_mail( $by_fan['FAN-1'][0], $subject, $body, $headers );

Every guardian on the child's record, not only the payment contact — this answers "who should be told about this child?". A player with no guardian email is absent rather than present and empty.

Sending is deliberately not on this seam. CPM_Mailer hooks wp_mail() globally for the from-address, the outbound log, the logging-only redirect and API delivery, so a satellite calling wp_mail() already gets all of it, and a CPM_Platform::send() would only wrap what is already happening. Only the addresses have to cross. CPM_Mailer itself stays internal.

send_as_headers() is the exception, and only because the header name is a contract with a third plugin (Club Mailer) and one place to change it is the point. It returns the header that routes a message through a member's own connected mailbox, or nothing at all when the plugin is absent or that member has not connected one — so it can be merged in unconditionally. send_as_address() names the mailbox for telling somebody what their message went out as; '' means "not known", never "use their profile email".

CPM_Platform::squads()

foreach ( CPM_Platform::squads() as $squad ) {
    // $squad->id, $squad->name, $squad->secretary_email
}

For a satellite that has to point at a squad — English FA records which squad each of its discovered teams is, so a match invitation can be addressed to the right team secretary. Squads stay core's: a satellite holds the id and asks here for the rest.

The whole list rather than a lookup by id, because every caller is either filling a dropdown or resolving one id it already holds, and a club has tens of squads. secretary_email may be empty — a squad with nobody assigned is a real state, and a caller must handle it rather than assume an address.

CPM_Platform::confirmed_players( int $season_id = 0 )

foreach ( CPM_Platform::confirmed_players() as $player ) {
    // $player->id, ->fan_id, ->full_name, ->squad_ids, ->has_fa_number
}

The club's players for a season: the ones whose parent said yes, or whose approval an admin recorded for them. Pass 0 (the default) for the active season. It is the question every piece of follow-up paperwork starts from — who has the club actually taken on? — and the answer is core's, because the five-state registration status and which of those states count as confirmed are both internal and both have changed before.

Children marked as having left the club are never included, whatever their season status says. With no season active at all the list is empty, which is why a caller should pass the season_id from the action-list context rather than assume there is one.

has_fa_number is how you tell a real FA Number from a placeholder. A hand-added or waiting-list-converted child carries a generated MANUAL-… fan_id until an import supplies the real one, and that convention is core's — a satellite that hard-coded the prefix would be holding a copy of a rule it does not own. fan_id is still given, so a caller can show it; has_fa_number is what it should branch on.

squad_ids is the scoping key. It is what an action-list action carries to reach a team secretary rather than only club admins.

cpm_action_list_providers — actions on the club's action list

add_filter( 'cpm_action_list_providers', function ( array $providers ): array {
    $providers['efa_team_registrations'] = array(
        'label'    => __( 'FA team registrations', 'club-english-fa' ),
        'callback' => array( EFA_Action_Provider::class, 'missing_registrations' ),
        // 'capability' => 'cpm_access',   // optional; this is the default
    );
    return $providers;
} );

Adds a section to Club Management → Actions, the club admin's list of outstanding jobs. The callback is handed the context — is_club_admin, season_id, season_name — and returns a plain array of actions:

array(
    'id'        => 'missing-registration:7:FAN123',  // required, stable
    'title'     => 'Register Jess Apps with the FA', // required
    'detail'    => 'Confirmed for 2025/26 · Under 10s',
    'url'       => $player_url,                      // makes the title a link
    'squad_ids' => array( 3 ),
)

Actions are derived, never stored. A provider is asked afresh on every page load and answers from whatever it can see right now, so an action leaves the list by the job being done — not by anything having to retire it. That is what makes the list trustworthy, and it is the reason the seam is a filter rather than a cpm_action_* table a satellite writes into: the two plugins keep their own data, and the one that owns it answers for it. The one thing that is stored is a dismissal, and core owns that table.

id has to be stable and must not collide. It is what a dismissal is remembered by, so the same job on the same child has to produce the same id every time. Compose it from what the action is about — a season and an FA Number, say — never from a row id a re-import would change. Core namespaces it with the provider's own key, so it only has to be unique within one provider.

squad_ids decides who sees it. An action naming squads reaches those squads' team secretaries as well as club admins; an action naming none is club-wide and only club admins see it. Core applies this itself — a provider is never told which squads the current user may see, precisely so it cannot get the scoping wrong.

A provider that throws is treated as having said nothing, and one whose callback is not callable is skipped. A satellite's bad day costs its own section, not the screen.

Adding a filter moves CPM_Platform::VERSION, but a satellite registering a provider need not raise its floor: a core too old to fire the filter simply never calls back, which is a missing section rather than a fatal. What does move English FA's floor here is confirmed_players() above — calling a method that is not there is fatal, and that is the line the floor exists for.

cpm_squad_player_columns — extra columns on the squad page

add_filter( 'cpm_squad_player_columns', function ( array $columns, object $squad ): array {
    $columns['efa_teams'] = array(
        'label'  => __( 'FA teams', 'club-english-fa' ),
        'render' => array( EFA_Squad_Column::class, 'render' ),   // echoes escaped markup
    );
    return $columns;
}, 10, 2 );

Adds a column to the squad page's roster tables (the squad's players, and the players who declined the active season). render is called once per player with that player's row, and echoes the cell's contents — already escaped; core prints the <td> around it.

The player row carries id, fan_id, name and date of birth. fan_id is the join key: core holds no team data at all, so a satellite looks up whatever it owns against that child itself. English FA's Teams column is exactly this — core offers a column it has nothing to put in, and the plugin that owns team registrations answers it.

Core keeps ownership of the markup; the satellite decides only what to draw.

Core validates the array and silently drops an entry with no callable render, because the column count is load-bearing: the Payments expander's colspan is computed from it, and a cell count that disagrees with the header is a visibly broken table.

Adding a filter is an addition to this contract, so it moves CPM_Platform::VERSION — but a satellite using one does not necessarily need to raise its floor. A filter that an older core never fires simply never calls back, which is a missing column rather than a fatal; the floor exists for API whose absence would be fatal. A satellite that only registers a column can leave its floor alone.

cpm_squad_list_columns — extra columns on the squad list

add_filter( 'cpm_squad_list_columns', function ( array $columns, ?object $season ): array {
    $columns['efa_age_group'] = array(
        'label'  => __( 'Age group', 'club-english-fa' ),
        'render' => function ( object $squad ) { /* echoes escaped markup */ },
    );
    return $columns;
}, 10, 2 );

The squad-list counterpart of cpm_squad_player_columns: adds a column to the Squads table on core's Overview page, after the squad's name. render is called once per squad with the squad row (id, name, type, dob_start, dob_end) and echoes the cell's contents. Same validation, same reason.

The second argument is the active season, or null when none is set, with start_date always resolved (the recorded date, or the 1 August default) — so a squad attribute that moves each season can be computed without reaching into core. English FA's "Under X" age group is the case it was added for. Added in platform 3.9; registering it needs no floor change.


4a. Actions core fires about a player

Team registrations moved out of core and into English FA, which means nothing cascades between them any more — the registrations table holds no foreign key into cpm_players, and that is what keeps the two independent. Core therefore announces the three things a satellite holding rows against a child has to know about.

Action Fired Arguments
cpm_imported_team_registration per team registration read out of an FA player report $fan_id, $team, $reg_data
cpm_before_player_deleted before a player's own rows are deleted $player_id, $fan_id
cpm_player_fan_merged when a duplicate merge moves one FA Number onto another $remove_fan, $keep_fan

Three things about these are load-bearing:

cpm_before_player_deleted fires first, and carries the FA Number. The player row is about to go, so a listener that needed to look the FAN up afterwards would find nothing.

cpm_player_fan_merged fires inside the merge's transaction. Both plugins write through the same $wpdb connection, so a listener's writes are covered by core's START TRANSACTION — a mid-merge fatal rolls their rows back with core's rather than leaving them half-moved. A listener owns its own collision rule.

cpm_imported_team_registration is an action, not a dependency. Core parses the report's team columns — it is one workbook, and the same rows carry the players and guardians core does own — but stores nothing. With no listener the registration columns are simply not saved; every other column still imports.


4b. The player detail extension point

cpm_player_detail_cards fires between the squads and guardians cards on a player's page, with the player as its argument. Anything echoed lands between the two, so a listener should print a complete tw:card block (see ADMIN-UI.md §6) — the page has already opened the Tailwind scope by the time this fires, so a listener's markup renders inside it, not outside. This is where core's own Team Registrations card used to be; English FA draws it there now (EFA_Admin_Registrations::render_player_card() / partial-player-registrations.php).


4c. The guardian account extension point

add_filter( 'cpm_account_links', function ( array $links, string $email ): array {
    $links[] = array(
        'label'       => __( 'Your matches', 'club-english-fa' ),
        'url'         => EFA_Guardian_Link::matches_url(),
        'description' => __( 'The matches your children have been invited to.', 'club-english-fa' ),
    );
    return $links;
}, 10, 2 );

Adds a link to the "More from the club" panel on core's guardian account page (/account/) — the one page a parent signs in to, and so where they look for anything the club asks of them. Core holds no opinion about what those pages are: a satellite that owns one contributes the link and words it itself. The panel is absent entirely when nothing is contributed.

label and url are required; an entry missing either, or one that is not an array, is dropped rather than rendered as a link to nowhere. description is optional and shown under the link.

The email argument is the load-bearing part. The filter fires only for a guardian who has already proved that address is theirs — core has authenticated them by emailed link or passkey (CPM_Guardian_Auth) — and it does not fire at all on a signed-out page. That is what makes it safe for a satellite to tailor links to that guardian. A callback must return links for that address alone; nothing in the request is involved, and nothing should be read from it. Prefer a link to a page that recognises the guardian by their session (current_guardian_email(), below) over minting a credential into the URL: a session can be signed out of, and a bearer URL cannot.

cpm_account_recognised_email — signing somebody in who is not a guardian

add_filter( 'cpm_account_recognised_email', function ( bool $recognised, string $email ): bool {
    return $recognised || CRF_Database::is_referee_user( $email );
}, 10, 2 );

Core's account sign-in — the emailed link and the passkey behind it — is the site's only public sign-in, and until this filter it would only ever open for an address the club holds a child against. A satellite whose pages belong to somebody who is not a parent (club-referees' referee list is the first) returns true for an address on its own list, and core then treats it exactly as it treats a guardian's: it will email that address a sign-in link, and it will accept a passkey registered against it.

The alternative it replaces is a second sign-in. Tokens, sessions, a WebAuthn ceremony and a parent's second "which email was it?" — all sitting beside core's on one site, for people who are mostly the club's parents anyway. One sign-in with an open guest list is the cheaper half of that trade by a long way, and the expensive half (the passkey) is the half nobody should implement twice.

Vouching says nothing about what that person may see. All it says is that the address may prove itself here. What is then shown to them stays the vouching plugin's own decision, made on its own pages against its own list — core's account page shows them their passkeys and whatever cpm_account_links contributes, and nothing else. A satellite must re-check its own list on every page it protects rather than treating "signed in" as "allowed": a referee taken off the list keeps a live session until it expires, and it is the page that has to turn them away.

Only ever return true. Answering false for an address core or another plugin already recognised takes away a sign-in that is not yours to take, so return $recognised unchanged rather than false.

Core still asks is_known_guardian() — never this — wherever the question is about children. Somebody recognised this way has an account and no children on file, and the account page leaves its "Your children at the club" panel out for them rather than reassuring them it is empty.

A satellite registering this filter need not raise its floor: a core too old to fire it never calls back, which costs that plugin's own people the emailed link rather than fataling. Added in platform 3.8.

CPM_Platform::current_guardian_email() / guardian_account_url()

$email = CPM_Platform::current_guardian_email(); // '' when nobody is signed in
wp_safe_redirect( CPM_Platform::guardian_account_url() );

For a satellite page that belongs to a guardian's account — English FA's /account/matches/. current_guardian_email() is the address core has authenticated on this request (emailed login link or passkey), lower-cased; the session, its cookie and how it was proved stay core's. guardian_account_url() is where to send a guardian who is not signed in. Added in platform 3.6.

A signed-in guardian's POST needs no nonce: the session cookie is SameSite=Lax, so a browser never attaches it to a cross-site form submission — the same defence core's own account routes rely on.


5. The cpm- prefix

The shared CSS classes and the test harness's globals keep their cpm- / cpm_test_ prefixes even though every plugin uses them. Read the prefix as the platform's namespace, not as a claim about which plugin owns the page.

The alternative is renaming ~30 templates, 374 lines of CSS and ~90 test files to a neutral prefix for no functional gain. That trade was made deliberately; see the monorepo notes in the repository.


6. What is deliberately not here

Three things were considered for the platform and left out, because the duplication they would remove is smaller than the abstraction they would add:

  • A settings base. CPM_Settings is 369 lines and almost all of it is this plugin's defaults — email bodies, GoCardless configuration, currency. The genuinely shared shape is "merge stored options over defaults", which is a handful of lines each.
  • A schema/migration base. The shared part is if ( get_option( $version ) !== $current ) install();. The interesting half — the migration version walk — is per-plugin by nature.
  • A repository base. The column allowlist is a few lines per repository, and the repositories otherwise have nothing in common.

The rule the platform is held to: extract what a second consumer demonstrably needs. Everything currently in includes/platform/ replaces code that was written twice.