English FA¶
A WordPress plugin that pulls fixtures and results from FA Full-Time for the teams a club registers players to, and publishes them through shortcodes and generated team pages.
It is a companion to Club Player Manager, not part of it, so the two can be installed, upgraded, backed up and removed independently. Everything about a club's FA registrations is this plugin's — the report upload, the registrations themselves, and the teams they name — while players, guardians and squads stay that plugin's. Neither queries the other's tables: this plugin reaches core only through the platform, and core reaches this one only by firing actions it may listen to. See docs/PLATFORM.md.
What it does¶
- Collects a league's Full-Time code snippet. Full-Time's ordinary fixtures/results pages
refuse automated requests, but a Code Snippet — the embed FA leagues hand out for other clubs'
websites — does not, because it is built to run on a stranger's browser. A snippet already
reports every one of the club's teams across every division and age group that league system
runs, so one league (one fixtures snippet, one results snippet) is normally all a club needs.
The site cannot run a snippet itself — that needs a real browser — so a separate collector
(
tools/pi-sync, meant to run on a Raspberry Pi on the club's own broadband) fetches it on a schedule and pushes the page in over REST. - Discovers the club's teams from what was collected, rather than being told about them. Set the club's name once (English FA → Settings → Club name); any home or away team in a collected league's matches containing that string is a team of the club's, published then and there — no team to add, rename or keep in sync by hand when a new age group appears.
- Publishes them. Four shortcodes for pages you build yourself, plus a generated page per team
at
/teams/<slug>/and an index at/teams/. - Imports the FA Club Player Report. Upload the club's player export (.xlsx). Club Player Manager parses it and saves the players and guardians it owns; the team-registration columns come back here and are stored in this plugin's own table. One workbook, read once, each half landing with the plugin that owns it.
- Keeps those registrations. One row per (player, team), keyed on FA Number, editable by hand on the FA Registration screen.
Requirements¶
- WordPress 6.0+, PHP 8.2+
- PHP's DOM extension (bundled with virtually every PHP build) — used to read Full-Time's HTML
- No Composer dependencies, and no build step
- A collector on a network Full-Time will serve — see
tools/pi-sync/README.md— since the site can never fetch a code snippet itself
Setting up a league¶
Go to Media → Code Snippets in Full-Time's own admin (you need to be a registered team or
league administrator there), generate the fixtures and results snippets for the league, and paste
each — the whole snippet, or just the number from its lrcode line — into English FA → Leagues
→ Add a league. There is no "sync now": a snippet only produces its table once a browser has run
it, which tools/pi-sync does on its own schedule and pushes in from there.
Once a league has collected at least once, its screen lists the team names its matches actually mention — compare that list against your club's name in Settings if a team you expect is not appearing; it is almost always a spelling difference, or the team playing in a league system you have not added here.
Shortcodes¶
[english_fa_fixtures team="U12 Reds" limit="5"]
[english_fa_results team="U12 Reds" limit="5"]
[english_fa_matches team="U12 Reds" fixtures="5" results="5"]
[english_fa_teams]
teamaccepts the team's name exactly as Full-Time writes it, or its page slug. Leave it out and the shortcode covers every published team, naming each row's team.limit(andfixtures/results) default to the values in English FA → Settings.show="competition,venue"chooses the optional columns;heading="…"changes or removes the heading.
Admin¶
English FA (top-level menu), all gated on the efa_manage capability — granted to WordPress
administrators and to Club Player Manager club admins (cpm_manage_all).
| Screen | What it is for |
|---|---|
| Teams | Every discovered team, with its squad, how many a side it plays, and its fixture and result counts. A team is not added here — only the squad and the format are set, and those on the team's own screen |
| Team (hidden) | Every one of a discovered team's matches, unlimited — the same content its public page shows, plus a Hide (keep off the public site without deleting it) and a Delete on each; one a collection no longer lists gets a warning icon rather than a section of its own. Above them, the two things about a team that are stored rather than collected: its squad and its match format (how many a side) |
| Match (hidden) | One match, reached from its date on the Team screen: the match's own details, and the club's players who were FA-registered to that team on the day it was played — plus, below them, the team's other registrations, so a player missing from the first list can be explained rather than only noticed. Registered players can be invited to the match, either by ticking them or with Invite All Registered Players; an invitation can be withdrawn the same way, and the same buttons sit above and below the table. The registered players are split across four tabs — Uninvited, Invited (asked, waiting), Available, Not available — which partition the list, so the tab counts add up to the Registered figure. A tick survives switching tabs, so the buttons act on everyone selected wherever they are. Each row shows the guardian's availability answer and when it last changed, and an admin can set or change it from the same dropdowns |
| Leagues | Add a league's two Full-Time code snippets; see when it was last collected and how many matches it holds |
| FA Registration | Every team registration, filterable by team and status; and the edit screen for one |
| Guardian Links | Each guardian, their children, and the pre-authorised link that shows them their children's match invitations. Read-only; nothing is emailed yet, so a link is copied from here |
| Import Players | Upload an FA Club Player Report (.xlsx); players and guardians go to Club Player Manager, registrations here |
| Settings | Club name (what makes a team "ours"), page address, shortcode defaults, and the shortcode reference |
It also draws into three screens it does not own, through extension points core leaves for it:
- Club Player Manager → Squads → a squad gains a Teams column naming the teams each
player on the roster is currently registered to, linking the ones it has discovered matches for. Core
offers the column and holds nothing to put in it (
cpm_squad_player_columns). - Club Player Manager → Squads gains an Age group column — "Under X", where X is the age
the squad's oldest date of birth reaches on 31 August of the year the active season ends
(
cpm_squad_list_columns). Blank with no active season or no start date of birth. - Club Player Manager → Players → a player gains that player's Team Registrations
card, where core used to draw one itself (
cpm_player_detail_cards).
The guardian matches page¶
A guardian's children's match invitations, with a Yes/No answer on each, live at
/account/matches/ — under Club Player Manager's account page, because that is the one
place a parent signs in. There are two ways onto it:
- Signed in (emailed login link or passkey, both core's —
CPM_Guardian_Auth). The page recognises the guardian throughCPM_Platform::current_guardian_email(); nothing is in the URL. Anybody not signed in is sent to the account page. This is the link core's account page shows under "More from the club". - By the link in an invitation email:
/account/matches/<token>/. The token alone lets the guardian see and answer their children's invitations — nothing else, and it never opens a session. It expires 7 days after it was issued; after that the page says so and points at signing in. A guardian who follows one while already signed in as that address is redirected to the plain page.
The token is EFA_Guardian_Link: 8 hex characters of issue time, then an HMAC over the address
and that time keyed by the site's auth salt. It carries no address — an email address in a URL
is an email address in every server log and Referer header — so resolving runs backwards:
resolve() checks the time first, then hashes the club's guardian addresses and compares in
constant time. Nothing is stored.
- A link cannot be revoked on its own, only outlived. The week is what bounds a forwarded or leaked email. Rotating the auth salt kills every link at once; a guardian leaving the club's records stops theirs resolving.
- The write is kept small. Every answer re-proves the guardian (token, or session if the token
has gone stale), re-reads that guardian's children from Club Player Manager rather than trusting
the form, and refuses anything but an answer to an invitation that already exists.
responded_byrecords which side set it. - Answering never extends a link. The form posts the token back and the guardian is returned to the page with the same token, not a fresh one.
- Answers are POSTed, never followed as links. A GET that changes state is set off by anything that fetches URLs on a reader's behalf — a mail client scanning links, a browser prefetching.
- CSRF is covered without a nonce, which a page served to a signed-out parent could not issue
usefully anyway: a signed-in answer is resolved only from core's guardian session cookie, and that
cookie is
samesite=Lax(CPM_Guardian_Auth::set_cookie()), so a browser will not attach it to a cross-site POST — there is no identity for a forged request to act as. The one request Lax still allows, a top-level GET navigation, writes nothing because every field the handler reads comes from$_POST. On the emailed-token path a nonce would add nothing at all: a logged-out visitor's nonce is a function of the action and the 12-hour tick, so anybody can compute one, while the token is unguessable and is the whole authenticator.GuardianAnswerGuardTestholds the handler's half of this. It is the same defence core's own account routes rest on. - Old links — the bare, never-expiring
?efa_guardian=<digest>form sent before this — grant nothing and redirect to/account/matches/, i.e. to sign in.
The routes are rewrite rules registered whether or not the generated team pages are on. A site on
plain permalinks gets ?efa_matches=1 and ?efa_guardian=<token> on the site root instead.
The club's own invitation email wording, if it was saved before this change, still says the link
can be used "at any time" — the default now says 7 days.
Invitation emails¶
Turned off by default. With it on (English FA → Settings), inviting a player emails their guardians, once, as the invitation is made.
- Every guardian on the child's record is written to, not just the payment contact — either parent answering is better than neither, and each gets their own link, since a link is derived from the address.
- Only the newly invited are mailed.
EFA_Invitations_Repository::invite()returns which players it actually added, so pressing Invite All Registered Players a second time queues nobody who was already invited. - It comes from the team secretary, not from whoever clicked Invite. A fixture secretary, a
club admin and a volunteer may all press the button, and a guardian replying wants the person who
runs their child's team either way. The sender is resolved from the team → the squad an admin
has set for it → that squad's
secretary_email, and then: - the secretary's own mailbox, when they have connected one through Club Mailer. This needs their WordPress account email to match the address on the squad, so it will often not resolve;
- otherwise the Invitation from address (default
fixtures@lajfc.org), with Reply-To set to the secretary — so the reply still reaches them.
Case 2 is the one to expect, and it is not a degraded path: a consistent, recognisable sender with
the right reply address. The admin notice says which address replies will go to. A team with no
squad, or a squad with no secretary, still sends — just without a reply address.
- Delivery is plain wp_mail(). CPM_Mailer hooks wp_mail() globally for the from-address,
the outbound log, the logging-only redirect to the test inbox and Elastic Email API delivery, so
all of that already applies. Only the addresses cross the platform seam.
- Subject and body are editable, with {players} (the children this message is about), {matches}
(the list of what they have been invited to, built by the plugin — a club should be editing the
sentences around the list, not expressing a loop in a textarea), {count}, {link} and {club}.
Blanking either field restores the default.
Batching¶
Nothing is sent in the request that made the invitations. The invitation row is the queue —
notified_at NULL means "this guardian has not been told yet" — and a WP-Cron sweep
(EFA_Invite_Queue) drains it. There is no queue table, and the sweep is crash-safe for free: a run
that dies half way leaves the rest unstamped for the next tick, and can never re-send what already
went, because the stamp is per row.
A guardian gets one email, not one per child per match. Pending rows are grouped and each group becomes a single message listing everything it covers. Groups are keyed on (guardian address, team secretary) rather than the guardian alone: one email can carry one Reply-To, so a parent with children in two squads gets one message per squad, each replying to the right person. That is the right answer, not a compromise — they are two conversations with two different people.
If any of a guardian's children has an unsent invitation made in the last hour, that guardian is not written to at all yet — not about invitations from yesterday, and not about a different squad. Picking a team is not one sitting: a secretary invites most of a squad, hears back about a doubtful player, adds them later. Once an hour passes with nothing new for any of their children, everything outstanding goes at once. A fresh invitation for one guardian never holds another's.
The hold list is read from the whole table, not from the batch's own rows. A batch reads a bounded window oldest-first, so the newest invitations are precisely the ones that fall off the end of it — deciding the hold from the window alone would send a guardian's older invitation without ever seeing their fresh one, and only once a club was busy enough to overflow a batch.
The follow-up run is booked for when the oldest waiting row could be ready, so an hour-long hold
costs one tick rather than sixty. A failed send is retried up to MAX_ATTEMPTS and then let go, so a
permanently bad address stops being tried. A player with no guardian email is stamped rather than
left, because nothing about it will change on the next tick — the gap shows on the Match screen,
which is where somebody can fix it.
A team's squad, and its match format¶
A discovered team has no row of its own — it is a name read live out of a league's collected
matches — with exactly two exceptions: which of the club's squads it is, and how many a side
its matches are played. Both are stored (wp_efa_team_squads), because both are things about a
team that cannot be worked out. Nothing in Full-Time's data says that "Anytown Colts U12 Reds" is
the U12 Reds squad, or that it plays 9-a-side; only a person knows.
Set them on the team's own screen (English FA → Teams → a team), each in its own card, where the Teams list also shows both as columns so an unset team is visible rather than quietly having no reply address and no format.
- The squad decides who a match invitation comes from and who the guardians reply to.
- The format is one of 3, 5, 7, 9 or 11 a side — the FA's own youth formats, offered as a list rather than typed, so nothing can be stored that nobody plays. It shows on the Match screen, where somebody is deciding how many players to invite, and on the team's public page and the team index. 0 means "not set", which the public pages leave off the page entirely rather than reporting.
Three properties matter:
- The row outlives the team. Clearing the collected matches, deleting a league, or Full-Time simply not listing a team over the summer all make it vanish from every screen. What is stored stays, so the next collection that lists it again finds its squad and format still set. Nothing deletes one but a person choosing "Not set" — and only clearing both removes the row.
- The two are set independently. They are saved by different forms, so saving one never overwrites the other, and clearing the squad on a team that plays 9-a-side leaves the 9 behind.
- It is keyed on the normalised team name, the same normalisation the registrations lookup uses, so it survives Full-Time writing the same team as "… FC" one season and "…" the next.
Data model¶
Five tables, all wp_efa_*:
| Table | Holds |
|---|---|
efa_leagues |
One row per league system: its name, its two Full-Time code snippet numbers, and the last collection's outcome |
efa_league_matches |
Every match a league's collected page listed. Matched between collections on team pairing + competition, not match_uid (which hashes in the date, so it changes when a fixture is rescheduled) — see below. A discovered team's fixtures and results are read live from this table — nothing else stores them |
efa_player_registrations |
FA team registrations: one row per (fan_id, team). Keyed on FA Number rather than a player id, so a registration survives its player being deleted and re-imported — and so this table holds no foreign key into Club Player Manager |
efa_team_squads |
What is stored about each discovered team — which of the club's squads it is, and how many a side it plays (players_per_side, 0 for not set): one row per team, keyed on the normalised name. The only stored facts about a team, and the only table nothing but a person ever deletes from — see above |
efa_match_invitations |
Who the team secretary is asking for a match, and what the guardian said back: one row per (match_id, fan_id), carrying availability, responded_at and responded_by. The three states a club cares about are "no row" (not asked), "row with blank availability" (asked, waiting) and "row with an answer" — not a fourth value nobody sets. responded_by is guardian or admin rather than a user id, because a guardian has no WordPress account and which side answered is the part anybody asks about |
efa_player_registrations: an FA report re-import refreshes it, but a registration corrected by
hand is not recoverable, so nothing clears it wholesale.
efa_match_invitations is keyed on FA Number for the same reasons, and cascades on both sides:
EFA_Leagues_Repository clears a match's invitations when the match goes (an invitation to a
match that no longer exists can never be shown or answered), and EFA_Club_Sync carries them
through a player deletion or a duplicate merge exactly as it carries registrations.
Registrations arrived here from Club Player Manager's wp_cpm_player_team_registrations,
which this plugin copies on upgrade — see EFA_Schema::adopt_club_registrations(). It is a
copy rather than a move, so the two deploys can land in either order and a rollback still
finds its data.
How a collection behaves¶
- Per (league, source), matched by team pairing + competition. A row already held for that
pairing is updated in place — so a reschedule changes
kickoff_date/kickoff_timeon the same row rather than reading as one match withdrawn and an unrelated one added. - Nothing is deleted outright. A pairing a previous collection held that this one does not
list at all is stamped
missing_since, not removed — Full-Time gives no way to tell "genuinely withdrawn" apart from "a one-off gap in this particular collection", so that call is left to a person: the Team admin screen marks that row with a warning icon rather than treating it any differently, and Hide or Delete is the same action either way. Reappearing in a later collection clears the stamp again.EFA_Discovered_Teamsnever shows a flagged or hidden row to a visitor — hidden is a separate, admin-only decision a collection never sets or clears itself. - Except when the collected page came back empty, which is far more often nothing having been played yet than a fault. That is still reported as a successful collection — only a failed fetch (Full-Time refused the collector, say) is an error.
- Per feed. Collecting fixtures never touches rows collected from the results feed, and a since-emptied results feed cannot undo a good fixtures collection.
- Pushed, never pulled. The site has no cron of its own for this — it cannot run a code snippet
either way — so nothing happens here until
tools/pi-syncposts a page in.
Development¶
Everything runs from the repository root, where the shared dev tooling lives:
composer test # both plugins' suites
composer test:efa # this plugin only
composer lint # both plugins' phpcs rulesets
composer lint:efa # this plugin only
composer build:efa # dist-club-english-fa/club-english-fa-<version>.zip
The suites stub WordPress and run against a real SQLite database (borrowed from the club plugin's
test harness — the double is generic and test-only). All SQL must work on SQLite as well as MySQL:
no GROUP_CONCAT with ORDER BY, no information_schema, no multi-table UPDATE … JOIN. Do
complex grouping in PHP, as EFA_Discovered_Teams::names() does.
Schema changes bump EFA_Schema::VERSION, extend the dbDelta in install() and add a
maybe_migrate() block using add_column_if_missing() — dbDelta does not reliably add columns
under the SQLite adapter the dev site runs.
Layout¶
club-english-fa.php Bootstrap: constants, require order, hooks
uninstall.php Drops every table and option
includes/
class-efa-schema.php Table definitions + the migration walk
class-efa-database.php Table names + a facade over the repositories
db/class-efa-leagues-repository.php Leagues, and their collected matches
db/class-efa-matches-repository.php Match identity (match_uid) and type constants — not DB-backed
db/class-efa-registrations-repository.php FA team registrations
class-efa-access.php The efa_manage capability
class-efa-settings.php Options: get(), allowlisted save()
class-efa-snippet.php Parses/builds a Full-Time code snippet's div id + loader URL
class-efa-club-sync.php Listens to Club Player Manager: import, delete, merge
class-efa-fulltime-client.php HTTP fetch + the host allowlist (used by Test tools, not a collection)
class-efa-fulltime-parser.php Full-Time response -> match rows
class-efa-sync.php A collected league page -> stored match rows
class-efa-discovered-teams.php A club's teams, discovered by name from collected matches
class-efa-rest.php The feed list + push-import routes tools/pi-sync calls
class-efa-view.php Match formatting shared by partial-matches.php and the admin
class-efa-frontend.php Rewrite rules + the generated pages
class-efa-shortcodes.php The four shortcodes
class-efa-squad-column.php The FA teams column on Club Player Manager's squad page
admin/ One handler per screen, plus the orchestrator
templates/ admin-*.php, frontend-*.php, partial-*.php
assets/css/ assets/js/
tests/Unit/ tests/Integration/ tests/Support/
tools/pi-sync/ The collector: runs on a machine Full-Time will serve,
posts collected pages to /wp-json/efa/v1/feeds/import
The parser is the piece most likely to need work, because it is the piece reading somebody else's
markup. It is deliberately written to the shape of a fixtures table — a date, two team names, and
either a score or nothing — rather than to CSS selectors, so a Full-Time redesign does not
necessarily break it. Everything it can read is covered in tests/Unit/FullTimeParserTest.php;
adding a new feed shape means adding a case there and a branch in the parser, and nothing else.