Technical Documentation for Events System
Audience: developers/agencies who will maintain, extend, or debug the Events system on this site. This is not the client-facing guide (see the “Events” page of this user guide for that) — this page documents the actual implementation: ACF structure, custom post type/taxonomy configuration, blocks, and the PHP hooks in the seventy theme, along with the non-obvious caveats that shaped their design.
All code referenced lives in the seventy theme (child/custom theme) inside this Bedrock-structured WordPress install:
web/app/themes/seventy/inc/helpers.php— all custom PHP logic described belowweb/app/themes/seventy/inc/block-loader.php— auto-registers every folder under/blocks/containing ablock.jsonweb/app/themes/seventy/blocks/event-*andevents-similar— the custom Events blocksweb/app/themes/seventy/patterns/events-*— reusable patterns built from those blocksweb/app/themes/seventy/templates/single-event.html— the Single Event FSE template
1. ACF Structure
1.1 “Events” field group (event post type)
Field groups on this site are managed via the ACF admin UI directly on each environment’s database, not via acf-json sync. There is no acf-json/ directory in this theme. This is a significant caveat — see §5.1.
Field group name: Events. Key fields (ACF field names, not labels — these are what get_field() uses):
| Field (group) | Subfields | Notes |
|---|---|---|
date_and_time (group) | multi-day_event (true/false), date (single-day date_picker, shown when not multi-day), start_date / end_date (date_pickers, shown when multi-day), start_time / end_time (time_pickers), date_time_detail (text) | See §5.2 — critical caveat on date storage format |
location_details (group) | location (text, short venue name), location_detail (text), virtual (true/false, default on), location_address (wysiwyg, shown when not virtual), maps_link (url, shown when not virtual) | |
registration_and_cost (group) | free (true/false, default on), cost (text, shown when not free), cost_detail (text), calls_to_action (repeater: call_to_action_text, call_to_action_link) | See §5.3 |
promo_image (image, return format: array) | — | Used both inline and as a downloadable flyer |
partners (relationship → partner post type) | — | Rendered via event-partners block, which uses WP_Block to invoke event-meta with explicit context |
recap (group) | recap_title, recap_summary (wysiwyg), images (repeater, max 4), gallery (FileBird folder field), file_attachments (repeater: file, link_title), preload_attachments (true/false) | See event-recap/render.php for the three-source gallery logic (repeater images, FileBird gallery, or both) |
Postmeta key prefixing: every subfield above is stored with its parent group name prefixed, e.g. the raw postmeta key for date_and_time.date is date_and_time_date, not date. This is standard ACF Group behavior, but it means any raw SQL/meta_query work must account for it. In practice, this codebase avoids raw meta_query entirely for these fields — see §5.2.
1.2 Taxonomies
Three non-hierarchical-in-practice-but-registered-hierarchical taxonomies, all applied to event:
event-category— includes afeaturedterm used by the Featured Events pattern (see §5.6)event-seriesevent-type
event-series and event-type are the two tiers used by the Similar Events matching algorithm (see §4.6).
1.3 “C70 Site Settings” ACF Options Page
- Menu slug:
c70-site-settings, capabilitymanage_options(admin-only — deliberately raised from ACF’s defaultedit_posts, since this settings screen will likely accumulate more site-wide config over time). - Single field so far:
all_events_page— type Page Link (not Post Object, not URL), restricted topost_type: pageonly,allow_archives: 0. - Read via
get_field('all_events_page', 'option'), which returns a permalink URL string (Page Link fields return a URL directly, not a post ID — if you need the post ID, resolve it viaurl_to_postid(), which the breadcrumb code does). - Consumed by two places: the “See All Events” button in
events-similar/render.php, and the breadcrumb-ancestor injection inhelpers.php(§4.4). Both fall back gracefully if this field is empty (button falls back to a hardcodedhome_url('/take-action/attend-an-event/'); breadcrumb injection simply no-ops). - This field is empty by default on a fresh environment/DB and must be set manually per-environment, since it’s DB-stored, not code.
2. Custom Post Type Registration (event)
Registered via ACF (UI-managed, not register_post_type() in PHP). Key configuration as of this writing:
hierarchical: true, supportspage-attributes— meaning it behaves like Pages in the admin (title-sorted by default, supports a Parent dropdown, supports nested slugs viaget_post_ancestors()/post_parent), even though no events actually usepost_parentin practice (all imported/created events havepost_parent = 0).has_archive: false— deliberately disabled. See §5.5.rewrite.slug: take-action/attend-an-event/event(no trailing slash — see §5.4),with_front: false.- Single template:
templates/single-event.html(FSE).
2.1 Why the slug has an /event/ segment
Events do not live directly at /take-action/attend-an-event/%postname%/. That was the original design, but it created a routing collision: /take-action/attend-an-event/ is also the URL of a real, editor-managed Page (“Attend an Event”), and WordPress’s rewrite rule matching checks custom-post-type permastructs before Page rules (Page rules are always registered last/lowest-priority in WP_Rewrite, by design, so they act as a catch-all). Because the Events CPT’s rewrite base equalled that Page’s own URL, any child Page created under “Attend an Event” 404’d — WordPress matched the URL against the Events permastruct first (interpreting the child page’s slug as an attempted event slug), failed to find a matching event, and 404’d instead of ever considering the Page rewrite rules.
Adding the /event/ segment (take-action/attend-an-event/event/%postname%/) fully separates the two URL namespaces. If this slug is ever changed again, be aware of this collision class of bug — any CPT slug that is a prefix-match of a real Page’s own path (or a page that could exist under it) is at risk, regardless of whether the CPT is hierarchical.
3. Templates & Blocks
3.1 templates/single-event.html
Structure (abbreviated): header → seventy/site-page-header-cpt pattern (breadcrumbs + CPT label) → two-column layout (event-meta, post title, post content, event-recap, event-promo-image in main; event-date-full, event-location-full, event-cost-full in aside) → event-partners → events-similar → footer.
All the custom blocks below are dynamic (render.php, no static save output) and read postId/postType from block context, falling back to get_the_ID() when context isn’t supplied. This fallback is why they render correctly both as top-level blocks on the Single Event template and nested inside a Query Loop’s Post Template — but see §5.7 for the one place this required extra handling.
3.2 Block inventory
| Block | Purpose | Notes |
|---|---|---|
event-meta | Renders hidden/structured meta (used internally by other blocks via WP_Block invocation, e.g. inside events-similar cards) | |
event-date-full / event-location-full / event-cost-full | Sidebar detail blocks on the single template | event-cost-full has a documented historical bug — see §5.3 |
event-date-brief / event-location-brief | Compact icon+text versions for card/grid contexts (Query Loop) | Built specifically for events-featured/events-upcoming-three/events-past-three patterns |
event-recap | Post-event summary: title override, wysiwyg summary, up to 4 images (repeater) and/or a FileBird gallery block (both can print simultaneously if both are populated — not mutually exclusive), file attachments, heading-level adjustment based on whether preload_attachments is set | |
event-promo-image | Optional flyer/social image, with a share: true attribute enabling core/image‘s native lightbox | Rendered via <!-- wp:seventy/event-promo-image {"share":true} /--> — must be self-closed (/-->); a past bug (missing /) caused WordPress’s block parser to treat everything after it in the template as nested inside this one unclosed block, corrupting the rest of the page |
event-partners | Grid of related partner posts (ACF relationship field), wraps its own conditional markup | Only prints if partners exist |
events-similar | 3-up grid of related events + “See All Events” button | See §4.4 for the button’s URL source |
3.3 Patterns using the Query Loop status filters
events-upcoming-three.php and events-past-three.php (and their *-headline wrapper variants) use a native core/query block with the marker class seventy__query-events-upcoming / on the Query Loop block itself — see §4.1–4.3 for why it must be there and not on Post Template.seventy__query-events-ended
4. inc/helpers.php — Function & Hook Reference
4.1 seventy_inject_event_status_into_query_context()
Hooked to render_block_context (priority 10, 3 args). Fires for every block as it’s about to render, given ($context, $parsed_block, $parent_block). When $parent_block->name === 'core/query', reads that block’s own className and — if it matches one of the two marker classes — writes into seventy__event_status$context['query'].
Why this exists / why it’s structured this way: query is a real, declared context key (core/query‘s providesContext) that cascades to every descendant block regardless of nesting depth, independent of whether intermediate wrapper blocks (Post Template, Query Pagination) themselves declare usesContext: ['query']. Enriching it once, at the Query Loop’s own direct children, means every consumer downstream — Post Template and Query Pagination Numbers/Next/Previous and Query Total — sees the same marker. See §5.8 for the bug this fixes.
4.2 seventy_filter_events_query_by_status()
Hooked to query_loop_block_query_vars (priority 10, 2 args). Reads $block->context['query'][' (populated by §4.1) and, if set to seventy__event_status']'ended' or 'upcoming', overrides the query’s post__in (from §4.5) and orderby to 'post__in', and unsets any meta_query.
Editor-facing effect: add the CSS class or seventy__query-events-ended to a Query Loop block’s Advanced → Additional CSS Class(es) field, filtered to post type “Event”. No custom block required.seventy__query-events-upcoming
Known limitation: per WordPress core’s own documentation on this filter, it only affects the front-end query — the block editor’s Query Loop preview (which goes through the REST API, not this filter) is not filtered. This is a WordPress limitation, not a bug in this implementation.
4.3 seventy_get_event_ids_by_status()
The actual date logic behind §4.2 (and reused by nothing else currently, though it’s the natural place to extend if a third status bucket is ever needed). Pulls all published event IDs, computes each one’s start/end bounds via seventy_get_event_datetime_bounds() (a get_field()-based helper, not raw SQL), buckets into ended/upcoming, and sorts by real date (most-recently-ended-first for ended, soonest-first for upcoming).
Cached in the seventy_events object cache group, keyed per-status per-day (current_time('Ymd') in the cache key), TTL HOUR_IN_SECONDS. See §5.9 for the practical implication.
4.4 seventy_add_event_breadcrumb_ancestors()
Hooked to block_core_breadcrumbs_items — this is WordPress core’s native core/breadcrumbs block filter, not Yoast SEO’s. See §5.10 — this distinction cost real debugging time and is worth internalizing before touching breadcrumbs again.
For a single event (is_singular('event')), resolves the all_events_page ACF option (§1.3) to a post ID via url_to_postid(), walks its real ancestor chain via get_post_ancestors(), and splices those Page crumbs into the trail right after “Home.” Result: Home > Take Action > Attend an Event > {Event Title} — entirely derived from the Page’s hierarchy, not the event’s own URL or post_parent (which is empty for all events). This is deliberate: it means the trail is unaffected by any future change to the Events CPT’s rewrite slug (verified directly when the /event/ segment was added — see §2.1).
4.5 Admin list: seventy_sort_events_admin_list_by_date(), seventy_add_event_date_column(), seventy_render_event_date_column(), seventy_event_date_column_sortable()
Because event is a hierarchical CPT, WordPress’s admin list table (Posts → Events) defaults to alphabetical-by-title sorting, same as Pages — not by date, unlike normal (non-hierarchical) post types. These four functions together:
- Add a sortable “Event Date” column (inserted just before the native “Date”/publish-date column).
- Override the default sort (when no
orderbyis in the request) to real event date, most-future-first. - Handle explicit clicks on the “Event Date” column, respecting
asc/descfrom the request. - Step aside for any other explicit column sort (Title, Author, etc.).
- Respect the current status tab (Published/Draft/Trash) and any active search term when building the candidate list, so filtering + sorting compose correctly.
Sorts in PHP (same seventy_get_event_datetime_bounds() approach as everywhere else), not cached (unlike the front-end filters) — admin freshness matters more than performance here, and the traffic pattern (a handful of editors) doesn’t warrant it.
4.6 seventy_get_matching_events() / seventy_get_similar_events()
Powers the events-similar block. seventy_get_similar_events($post_id, $count = 3) tries event-series matches first, then event-type to fill remaining slots, then falls back to any event (no taxonomy constraint) if still short — always returning $count results if enough events exist at all. Within each tier, prefers soonest-upcoming, then most-recently-ended. Cached in seventy_events, same daily-key/1-hour-TTL pattern as §4.3.
4.7 Other utility functions
seventy_format_date_range(), seventy_get_event_datetime_bounds(), seventy_has_event_ended(), seventy_get_event_display_date(), seventy_get_event_display_time() — the shared date-resolution layer nearly everything above builds on. seventy_render_term_links() and seventy_render_document_list() are generic rendering helpers (term link lists; the file-attachment “routing document” markup reused from an existing pattern) not specific to date logic.
5. Caveats & Warnings
5.1 ACF config is not version-controlled
There is no acf-json/ sync folder in this theme. Field groups, the Options Page, and post type/taxonomy registration are all database-stored, per-environment. This means:
- A fresh environment (new staging site, disaster recovery, etc.) will not have the Events field group, taxonomies, or the
eventpost type at all until someone exports/imports or manually recreates them via the ACF UI. - Config can drift between environments (local/staging/production) silently — the ACF JSON exports referenced throughout this project’s development were manual, point-in-time snapshots shared for review, not an automated sync.
- If migrating to
acf-json, note that would change how ACF resolves field group precedence (JSON takes priority over DB by default) — do this deliberately, not incidentally.
5.2 ACF date fields are not stored consistently
This is the single most important caveat for anyone extending this system.
The date_and_time group’s date/start_date/end_date fields are ACF date_picker fields, which normally always store Ymd (e.g. 20260904) in postmeta regardless of display format — but the historical WXR import used to populate ~230 events wrote postmeta directly (bypassing ACF’s own save routine), storing the field’s display-formatted string instead (e.g. "March 20, 2016"). Both formats now coexist in the same postmeta key across different posts, depending on whether the event was imported or created/edited through the normal ACF UI afterward.
Consequence: any raw SQL comparison (meta_query, orderby=meta_value) against these fields is unreliable — it will silently produce wrong results (not an error) for a mix of Ymd and display-string rows. This is why every date-based query/sort/filter in this codebase (§4.2, §4.3, §4.5, §4.6) resolves dates via get_field() (which normalizes both formats correctly through acf_format_date()‘s use of PHP’s strtotime()) and does the actual filtering/sorting in PHP, using post__in + orderby=post__in to hand WordPress a pre-sorted, pre-filtered ID list rather than asking SQL to do it. Do not introduce a new meta_query or orderby=meta_value against these specific fields without normalizing the stored data first (a one-time migration script rewriting all postmeta to consistent Ymd would remove this constraint, but hasn’t been done).
5.3 calls_to_action can be false, not just empty
ACF Repeater fields with zero rows return false from get_field(), not an empty array. A historical bug in event-cost-full/render.php assumed an empty string/array fallback (?? '') and passed that directly to array_filter(), causing a fatal TypeError (array_filter(): Argument #1 must be of type array, bool given) on every imported event with free = true and no CTA rows — a large fraction of the imported set. Fixed by explicitly coercing to array(). If you see a similar fatal on a repeater field elsewhere, this is almost certainly the same class of bug.
5.4 Rewrite slug changes require a permalink flush
Changing rewrite.slug (or has_archive, or with_front) on the event post type via the ACF UI updates the registered configuration but does not automatically flush WordPress’s compiled rewrite_rules option. Until something flushes it (visiting Settings → Permalinks and clicking “Save Changes” is the simplest way, or wp rewrite flush via WP-CLI), the site continues routing against the old rules — this produced confusing intermittent 404s and mis-rendered breadcrumbs twice during this project’s development. Always flush permalinks immediately after any post type/taxonomy rewrite change, on every environment separately (a flush on staging does not affect production).
On Pantheon specifically, also be aware the edge cache (Varnish) is a separate layer from WordPress’s rewrite rules — clearing WordPress’s cache does not clear Pantheon’s, and vice versa. A stale cached 404 can outlive a correct permalink fix until the Pantheon cache is separately cleared (dashboard “Clear Caches,” or terminus env:clear-cache).
5.5 No native archive, by design
has_archive is deliberately false. The “events listing” experience lives on a real, hand-authored Page (/take-action/attend-an-event/, referenced via §1.3’s all_events_page field) using Query Loop patterns (§3.3), not WordPress’s generic CPT archive template. If a future developer is tempted to re-enable has_archive for some reason (e.g. to get automatic pagination/archive-title SEO handling), be aware this reintroduces the exact routing collision described in §2.1, and Yoast SEO still has orphaned (harmless but unused) archive-related settings for this post type (title-ptarchive-event, bctitle-ptarchive-event, etc. in the wpseo_titles option) left over from before this decision.
5.6 Hardcoded term ID in events-featured.php
The Featured Events pattern’s Query Loop filters by event-category term ID 32 ("taxQuery":{"include":{"event-category":[32]}}), because that’s how Gutenberg’s Query Loop taxonomy-filter UI always serializes a category selection — as a raw term ID, not a slug. Term IDs are database-row identifiers, not guaranteed to match across environments — a fresh import/environment could easily assign the “featured” term a different ID. If this pattern stops showing featured events after a migration or fresh environment setup, this is the first thing to check: re-open the pattern in the editor and re-select the “Featured” category from the filter, which re-serializes it with that environment’s correct ID.
5.7 ServerSideRender does not forward block context
event-date-brief and event-location-brief are designed to work inside a Query Loop’s Post Template, where each iteration should render with that iteration’s own post as context. However, the editor’s ServerSideRender component (used for the edit() preview of every dynamic block in this theme) does not automatically forward Gutenberg block context (postId, etc.) to its REST API call — it’s a known Gutenberg limitation. Both blocks’ index.js work around this by reading the context prop Gutenberg does pass to edit() (since both declare usesContext in block.json) and forwarding it explicitly as urlQueryArgs: { post_id: postId }, which the underlying /wp/v2/block-renderer REST endpoint uses to setup_postdata() the correct post before rendering. Any new dynamic block intended for use inside a Query Loop must do the same, or its editor preview will render against whatever post happens to be open in the editor (wrong data, or “block rendered as empty”) — the frontend render is unaffected either way, since real Query Loop rendering does populate context correctly; this is purely an editor-preview issue.
5.8 A Query Loop’s total count and pagination are built by separate sibling blocks
Covered in detail in §4.1, but worth restating as a general warning: any future filter on query_loop_block_query_vars that reads state off $block directly (attributes, className) will only ever see whichever specific block WordPress happens to be building the query for at that moment — which is not consistently the Post Template block. WordPress independently calls build_query_vars_from_query_block() from Post Template, Query Pagination Numbers, Query Pagination Next, Query Pagination Previous, and Query Total, each with their own $block instance. Reading a marker off $block->context[...] (populated once via render_block_context on the parent core/query block, as §4.1 does) is the correct pattern; reading off $block->attributes directly is not, and will produce exactly the “pagination shows the wrong count” bug this codebase already hit once.
5.9 Caching — no cache invalidation on save
§4.3 and §4.6 both cache their results in the seventy_events object cache group for up to HOUR_IN_SECONDS, keyed per-day. There is no save_post hook clearing these caches when an event’s date or taxonomy terms are edited. In practice this means: after editing an event’s date, the upcoming/ended Query Loop filters and the Similar Events block may take up to an hour to reflect the change (the single event page itself is never cached and always reflects live data). If this becomes an issue for editors, the fix is a save_post_event (or generic save_post) hook calling wp_cache_delete() for the relevant keys — not currently implemented. Note also this project has no persistent object cache backend confirmed (Redis/Memcached) — without one, wp_cache_* calls are request-scoped only via WordPress’s default in-memory object cache, meaning this caching layer currently does nothing across requests in that scenario and every page load recomputes from scratch. Confirm whether a persistent object cache is active on the target environment before relying on this caching for performance.
5.10 This site uses core core/breadcrumbs, not Yoast
Yoast SEO is installed and does register its own yoast-seo/breadcrumbs block, but it is not what’s used here. The pattern markup (<!-- wp:breadcrumbs ... /-->) resolves to WordPress core’s native core/breadcrumbs block (shipped in WP 7.0+) via the standard unprefixed-comment-defaults-to-core/-namespace convention. This is easy to get wrong — an earlier attempt to customize the breadcrumb trail via Yoast’s wpseo_breadcrumb_links filter had zero effect, silently, because that filter was simply never being called. Any future breadcrumb customization must hook block_core_breadcrumbs_items (fired from wp-includes/blocks/breadcrumbs.php), not any Yoast filter. If Yoast’s breadcrumbs feature is ever intentionally switched to instead, all of §4.4’s logic would need to be ported to the Yoast equivalent (wpseo_breadcrumb_links) and re-verified — the two systems build their trails completely differently (Yoast walks its own Indexable/ancestor system; core walks post_parent for hierarchical types, or taxonomy terms otherwise).
5.11 Events CPT hierarchy fields are present but unused
event supports page-attributes and is registered hierarchical: true, giving every event a “Parent” dropdown in the editor — but no events actually use it (post_parent is 0 for all ~230 events as of this writing). This is load-bearing for the WordPress admin default-sort behavior described in §4.5 (hierarchical post types sort by title by default) even though the hierarchy feature itself isn’t otherwise used. If a future developer is tempted to set hierarchical: false to “simplify” the post type, be aware this changes the default admin sort behavior (§4.5’s override would no longer be strictly necessary, but is harmless either way) and would need to be tested against §2.1’s rewrite-collision fix (a non-hierarchical CPT registration builds its rewrite rule differently, though the same fundamental collision risk with real Pages would still apply for the reasons described there).