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/requiresCPM_Platform::VERSIONto 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-managementmust declare a floor too; - a satellite that no longer works against the core on
mainmust 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:
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_Settingsis 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.