Skip to content

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

  1. 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.
  2. 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.
  3. Publishes them. Four shortcodes for pages you build yourself, plus a generated page per team at /teams/<slug>/ and an index at /teams/.
  4. 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.
  5. 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]
  • team accepts 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 (and fixtures / 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 through CPM_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_by records 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. GuardianAnswerGuardTest holds 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_time on 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_Teams never 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-sync posts 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.