Extending Eventonomy
Eventonomy is built to be molded. Every behavior worth changing has a documented seam. Pro is built on the same seams; your add-on has exactly the same extension depth.
Full reference:
docs/EXTENDING.mdin the plugin root. This page covers the most important patterns with working examples.
What You Will Learn
- The stable public API (SemVer guarantee)
- How to bind contracts and register services
- How to add a payment gateway
- How to add a notification channel
- How to register an import source and run it
- How to add a screen to the admin navigation rail
- How to use the Meta API for custom fields
- Internationalization (WPML / Polylang)
- Programmatic event creation
- The canonical attendee-count chokepoint and the shared display helpers
The Stability Promise
These surfaces are stable across major versions (no breaking changes without a deprecation cycle):
- All
evnm_*hooks - All
Eventonomy\Contracts\*interfaces - The
eventonomy/v1REST routes and envelope - The
eventonomyInteractivity store stable surface - Template override paths and
$view_datashapes - The Meta API functions
- The
wp eventonomyCLI group - The
evnm_setting()/evnm_settings()/evnm_feature_enabled()accessors
Never depend on concrete classes in Eventonomy\Repository\*, Services\*, or Admin\*; the DB column layout; or anything marked @internal.
Binding Services
Bind on the evnm_register_services action.
bind( Contract, Impl )- replace the single implementation resolved byevnm( Contract::class ). Later bindings win (how Pro layers on Free). Use for the repository contracts.tag( Contract, Impl )- add to a collection consumed withevnm()->tagged( Contract::class ). Only two contracts are consumed this way:NotificationChannelInterfaceandRecurrenceEngineInterface.
add_action( 'evnm_register_services', function ( $c ) {
// add-to-collection: register a recurrence engine (the materializer reads
// ->tagged() and dispatches by handles_rule()). A bind() here would be ignored.
$c->tag( \Eventonomy\Contracts\RecurrenceEngineInterface::class, MyRecurrenceEngine::class );
// add-to-collection: add a notification channel (the notifier fans out to
// every tagged channel whose supports() returns true).
$c->tag( \Eventonomy\Contracts\NotificationChannelInterface::class, SlackChannel::class );
} );
Payment gateways are not container-wired. Tagging
PaymentGatewayInterfacedoes not register a gateway; nothing consumes that collection. A gateway is advertised and charged through theevnm_payment_gateways+evnm_process_paymentfilters (next section).
Adding a Payment Gateway
Implement PaymentGatewayInterface for a clean object model, then wire it with two filters; this is how Pro's Stripe/PayPal/Woo gateways register. Tagging the interface alone does nothing.
class MyGateway implements \Eventonomy\Contracts\PaymentGatewayInterface {
public function id(): string { return 'my-gateway'; }
public function label(): string { return 'My Payment Gateway'; }
public function supported_currencies(): array { return [ 'USD', 'EUR' ]; }
// charge() returns an array with 'transaction_id' + 'status', or a WP_Error.
public function charge( array $order, array $args ) {
// Call your payment processor.
return [ 'transaction_id' => '...', 'status' => 'paid' ];
}
public function capture( string $transaction_id, array $args ) { /* ... */ }
// refund( string $transaction_id, int $amount_cents, string $currency = '' ): array|WP_Error
public function refund( string $transaction_id, int $amount_cents, string $currency = '' ) { /* ... */ }
}
// 1) Advertise it so checkout offers it.
add_filter( 'evnm_payment_gateways', function ( $gateways ) {
$gateways = (array) $gateways;
$gateways['my-gateway'] = [ 'id' => 'my-gateway', 'label' => 'My Payment Gateway' ];
return $gateways;
} );
// 2) Charge it when selected. Return null/prior $result if it's not yours.
add_filter( 'evnm_process_payment', function ( $result, $order, $input ) {
if ( null !== $result || 'my-gateway' !== ( $input['gateway'] ?? '' ) ) {
return $result;
}
return ( new MyGateway() )->charge( $order, $input );
}, 10, 3 );
Once both filters are wired the gateway appears in the checkout flow and can settle paid orders.
How a Paid Order Actually Flows
PaymentGatewayInterface is the object model, but at runtime a paid order is assembled from four Free-owned hooks (documented in Hooks & Filters). Understanding this seam is what lets an add-on replace Pro at any single point:
evnm_payment_gateways(filter): Free reads it to decide whether any gateway exists. Empty result → the paid Order button is hidden/disabled and paid orders return402. Your gateway must appear here to be sellable.evnm_order_total(filter,($subtotal, $input, $context)): Free prices line items from the authoritative ticket map; you (or Pro) layer coupons/tax/tiers on that base and return the final charge amount.evnm_process_payment(filter,($payment, $order, $input)):OrderService::create()calls this to actually charge. Return[ 'transaction_id' => …, 'status' => … ](the key istransaction_id, nottxn_id). With no gateway the value staysnulland the order settles only if it totals$0.evnm_after_update_order(action): later status changes (a webhook confirming payment, or a refund) fire here. Free's ownOrderService::reverse_on_refundis hooked to it: when an order transitions intorefunded/cancelledFree releases the reserved stock and voids the RSVPs. So a gateway/add-on only has to flip the order status and fire the action; Free reverses its own data both directions.
The mirror pair on the create side is evnm_after_create_paid_attendees (RSVPs materialized) and, on reversal, evnm_after_reverse_order (stock released); hook these for downstream side effects instead of re-deriving order state.
Adding a Notification Channel
Implement NotificationChannelInterface to add SMS, push, Slack, or any other channel:
class SlackChannel implements \Eventonomy\Contracts\NotificationChannelInterface {
public function id(): string { return 'slack'; }
public function label(): string { return 'Slack'; }
public function supports( array $recipient ): bool { return ! empty( $recipient['slack_handle'] ); }
public function send( array $recipient, array $message, array $context ): bool {
// Post to Slack API.
return true;
}
}
add_action( 'evnm_register_services', function ( $c ) {
$c->tag( \Eventonomy\Contracts\NotificationChannelInterface::class, SlackChannel::class );
} );
The notification service fans out to every channel whose supports() returns true.
Registering an Import Source
Every importer in Eventonomy - the CSV and ICS uploads, the five one-click plugin migrations, and Pro's remote calendar sync - resolves from one filter: evnm_import_sources. Register there and your source appears in the admin Importer tab, in WP-CLI, and as a usable source_type for Pro's saved feeds.
An entry is keyed by source id. source and mapper may be an instance or a factory closure; use closures so nothing is constructed until the source is actually used.
| Key | Required | Meaning |
|---|---|---|
label |
yes | Human label for the source picker. |
source |
yes | Eventonomy\Import\Contracts\ImportSourceInterface - or a closure returning one. |
mapper |
yes | Eventonomy\Import\Contracts\ImportMapperInterface - or a closure returning one. |
accept |
no | File-acceptance descriptor for the upload handler: ext (extension, no dot) and mimes (array; include '' for servers that detect no MIME). |
db |
no | true marks a source that reads existing data rather than an upload. No file staging anywhere in the flow. |
remote |
no | true marks a source fetched over HTTP from config. |
detect |
no | Callable returning an int count, used to auto-detect importable data. |
flag_labels |
no | Map of flag key → label for the scan report. |
The two contracts are small and live in Eventonomy\Import\Contracts (note: not Eventonomy\Contracts):
interface ImportSourceInterface {
public function id(): string;
public function label(): string;
public function rows( array $config ): array; // raw assoc rows
}
interface ImportMapperInterface {
public function map( array $raw ): array; // { input, source_ref, old_path?, image_url?, terms?, flags?, notes? }
}
add_filter( 'evnm_import_sources', function ( $sources ) {
$sources = is_array( $sources ) ? $sources : array();
$sources['my-feed'] = array(
'label' => __( 'My calendar feed', 'my-addon' ),
'remote' => true,
'db' => true, // reads from config, no upload
'source' => static fn() => new My_Feed_Source(),
'mapper' => static fn() => new My_Feed_Mapper(),
);
return $sources;
} );
Running a source with ImportRunnerInterface
Registration puts a source in the registry; Eventonomy\Contracts\ImportRunnerInterface is how you execute one from outside Free. It is the EP1-clean seam: a scheduled sync, a Pro feature, or a third-party add-on can drive an import to completion without ever naming Free's concrete ImportService.
use Eventonomy\Contracts\ImportRunnerInterface;
$runner = evnm( ImportRunnerInterface::class );
$result = $runner->run_source_to_completion(
'my-feed', // registered source id
array( 'url' => 'https://example.com/cal.ics' ), // source config
array( // import context
'author_id' => 7,
'user_id' => 7,
'dedup_source_id' => 'my-feed:42',
),
200 // rows per page (default 200)
);
// array{ ok: bool, created: int, skipped: int, redirects: int, total_rows: int, message: string }
Points that matter:
- Run it off-request. The method loops pages until the source is exhausted; it belongs in cron or an Action Scheduler worker, not a page load.
$batchis clamped to 1..1000, and the walk stops at 20,000 rows as a runaway guard. dedup_source_idnamespaces dedup and undo. When several subscriptions share one source id, give each its own value so their events stay independently tracked and independently removable. Omit it and the source id is used.user_idsets the author thatEventService::create()records, and drives the approval gate - a non-moderator's imports arrive as pending.- An unregistered id returns
ok => false, message => 'unknown_source'rather than throwing.
Pro's calendar sync is the reference consumer: see Calendar Sync Internals.
Importing one page at a time with run_source_page()
run_source_to_completion() finishes the whole source in one call, which is fine
for a small feed and wrong for a large one. run_source_page() is the resumable
counterpart: it imports ONE window and hands the cursor back, so you decide
whether to continue inline, re-queue as another job, or stop.
$page = $runner->run_source_page(
'my-feed',
array( 'url' => 'https://example.com/cal.ics' ),
array( 'user_id' => 7, 'dedup_source_id' => 'my-feed:42' ),
$offset, // 0 to start
100 // rows in this page
);
// array{ ok, created, skipped, redirects, total_rows, next_offset, done, message }
if ( ! $page['done'] ) {
// Queue the next page rather than looping here.
as_enqueue_async_action( 'my_addon_import_page', array( array( 'offset' => $page['next_offset'] ) ) );
}
Treat the walk as restartable, not resumable-at-all-costs. Dedup (via
dedup_source_id) is what makes starting again from offset 0 safe, so a lost
cursor costs a re-scan, never duplicate events.
Making your source readable in pages
By default a source only has to answer "give me every row", and Eventonomy slices the page it wants out of that array. Correct, but it means every batch reads your entire source. On a 20,000-event fixture that measured 96 MB per batch and 4,000,000 rows read to import 20,000 - which exhausts the PHP memory limit, and does so identically on every retry, so the import never finishes.
If your source can read a bounded window, implement the optional contract and Eventonomy will only ever ask for the page it is about to process:
use Eventonomy\Import\Contracts\PagedImportSourceInterface;
final class My_Feed_Source implements PagedImportSourceInterface {
public function id(): string { return 'my-feed'; }
public function label(): string { return 'My calendar feed'; }
// Still required - the unbounded read, used by anything that has not opted in.
public function rows( array $config ): array { /* … */ }
// COUNT it; never build the rows just to count them.
public function count_rows( array $config ): int { /* SELECT COUNT(*) … */ }
// Must equal array_slice( rows( $config ), $offset, $limit ).
public function rows_page( array $config, int $offset, int $limit ): array { /* … LIMIT/OFFSET … */ }
}
Implement it only if a bounded window is genuinely equivalent to the same
window of rows():
- Order must be stable across calls (
ORDER BY id ASC). An unstable order makes a paged walk skip and repeat rows. - If whether a row is emitted depends on OTHER rows, do not page the raw query. Eventonomy's own Events Manager importer skips a child event when its recurring master imports with a translatable rule, and that master can sit anywhere in the set - paging its SQL would duplicate every series. It pages the cheap partition instead and bounds only the expensive hydration.
Sources that do not implement it keep working exactly as before; there is nothing to migrate.
Adding a Screen to the Admin Rail
Eventonomy's admin is a single shell with one left navigation rail. An add-on screen belongs in that rail, not as a loose WP submenu the user has to find. evnm_admin_rail_groups is the seam.
// 1. Register the screen itself, under the eventonomy parent.
add_action( 'admin_menu', function () {
add_submenu_page(
'eventonomy',
__( 'My Screen', 'my-addon' ),
__( 'My Screen', 'my-addon' ),
'manage_options',
'my-addon-screen',
'my_addon_render_screen'
);
}, 20 ); // 20: Free's top-level menu (priority 10) exists by now.
// 2. Put it in the rail.
add_filter( 'evnm_admin_rail_groups', function ( $groups ) {
$groups = is_array( $groups ) ? $groups : array();
$groups[] = array(
'label' => __( 'Configure', 'my-addon' ),
'items' => array(
array(
'slug' => 'my-addon-screen',
'label' => __( 'My Screen', 'my-addon' ),
'icon' => 'plug',
'url' => admin_url( 'admin.php?page=my-addon-screen' ),
'match' => array( 'my-addon-screen' ),
),
),
);
return $groups;
} );
Item keys: slug, label, icon (a Lucide key), url, optional match (extra ?page= slugs that also mark the item active), optional pro, optional children. Full details and the join-an-existing-group pattern are in Hooks & Filters.
Meta API: Custom Fields
Store custom data against any Eventonomy resource:
// Read
$vimeo_url = evnm_get_meta( 'event', $event_id, 'vimeo_url', '' );
// Write
evnm_update_meta( 'event', $event_id, 'vimeo_url', $url );
// Delete
evnm_delete_meta( 'event', $event_id, 'vimeo_url' );
$type can be event, occurrence, rsvp, ticket, or order. These three functions are the entire Meta API: there is no evnm_register_meta (no typed-meta / REST-registration seam) and no evnm_registration_fields hook.
Exposing Meta in REST
To surface a meta value on a standard resource response, add it in the matching evnm_rest_prepare_{resource} filter, which is the REST-exposure seam:
add_filter( 'evnm_rest_prepare_event', function ( array $data, array $event, $request ): array {
$data['vimeo_url'] = evnm_get_meta( 'event', (int) $event['id'], 'vimeo_url', '' );
return $data;
}, 10, 3 );
Custom RSVP Fields
Extra RSVP questions are a configuration feature, not a developer hook: the site owner writes the shared question bank at Settings → Questions (gated on the custom_questions feature) and each event selects which of those questions it asks. Their answers are stored as one rsvp_answers meta key. Resolve an event's question list with evnm_event_registration_questions( $event ) rather than reading the bank or settings.question_ids directly. To read or add your own RSVP data programmatically, see Recipe: Custom RSVP Field.
Programmatic Event Creation
Use EventService::create() as the canonical entry point:
$service = evnm( \Eventonomy\Services\EventService::class );
$result = $service->create( [
'title' => 'My Programmatic Event',
'start_local' => '2026-09-01 18:00:00',
'end_local' => '2026-09-01 21:00:00',
'timezone' => 'America/New_York',
'status' => 'published',
'venue' => [ 'name' => 'Downtown Hub', 'city' => 'Boston' ],
], [ 'user_id' => 1, 'source' => 'cli' ] );
if ( is_wp_error( $result ) ) {
// handle error
}
Required fields: title, start_local. All other fields are optional; see docs/EXTENDING.md §13 for the full field reference.
Counting Attendees
"How many attendees does this event have?" has exactly one answer path. RsvpRepositoryInterface::count_by_event() is the canonical chokepoint every surface calls - the admin Events table, the My Events cards, the member dashboard, and the door-roster total all bind through it so the formulas cannot drift apart.
use Eventonomy\Contracts\RsvpRepositoryInterface;
$rsvps = evnm( RsvpRepositoryInterface::class );
public function count_by_event( int $event_id, ?array $statuses = null, bool $headcount = false ): int;
public function count_by_events( array $event_ids, ?array $statuses = null, bool $headcount = false ): array;
Two independent axes, because "attendees" is genuinely two different questions:
$statuses - which RSVP states count.
nullmeans every row, including cancelled. That is the raw table total, rarely what a UI wants.- An array is a whitelist. Use the published constants rather than a literal:
RsvpRepositoryInterface::STATUSES_ACTIVE=['going','maybe','waitlist']- everyone still expected at the event. This is the definition behind every "N RSVPs" number.RsvpRepositoryInterface::STATUSES_ATTENDING=['going']- confirmed attending. The basis of every headcount, capacity check, and "N going" badge.
- An empty array matches nothing and returns 0. It never falls through to an unfiltered count, so
count_by_event( $id, $user_selected_statuses )is safe when the user has deselected everything.
$headcount - the unit.
falsecounts rows: one RSVP is 1. This is the "N RSVPs" number.truecounts people viaSUM(1 + guests_count): the registrant plus their guests. This is the "N going" number, and it is the unit capacity is measured in.
// "N RSVPs" for one event.
$rsvp_rows = $rsvps->count_by_event( $event_id, RsvpRepositoryInterface::STATUSES_ACTIVE, false );
// "N going" headcount, guests included.
$going_people = $rsvps->count_by_event( $event_id, RsvpRepositoryInterface::STATUSES_ATTENDING, true );
Use the batched form in lists
count_by_events() is the same query for many events at once, returning event_id => count. Every requested id is present in the map, zero-filled when the event has none, so you never need an isset() dance.
count_by_event() is implemented as a single-id call into count_by_events(). Calling it per row inside a loop is therefore an N+1 by construction - one aggregate query per row. New code must batch:
// Correct: one query for the whole page.
$event_ids = array_column( $rows, 'event_id' );
$counts = $rsvps->count_by_events( $event_ids, RsvpRepositoryInterface::STATUSES_ATTENDING, true );
foreach ( $rows as $row ) {
$going = $counts[ (int) $row['event_id'] ]; // always set
}
// Wrong: one aggregate query per row.
foreach ( $rows as $row ) {
$going = $rsvps->count_by_event( (int) $row['event_id'], RsvpRepositoryInterface::STATUSES_ATTENDING, true );
}
Both forms run one aggregate query against the (event_id, status) index and are object-cached - never a fetched page counted in PHP, so a count cannot silently cap at a page size.
Latest occurrence end
OccurrenceRepositoryInterface::last_end_utc() answers "when does this event finally finish?" with one indexed MAX(end_utc) point lookup - the read the SEO layer uses to decide whether an event is entirely in the past.
use Eventonomy\Contracts\OccurrenceRepositoryInterface;
public function last_end_utc( int $event_id ): ?string; // 'Y-m-d H:i:s' UTC, or null
$end = evnm( OccurrenceRepositoryInterface::class )->last_end_utc( $event_id );
null means the event has no occurrences at all. Treat that as "unknown", never as "past" - an event awaiting materialization would otherwise be wrongly hidden.
Shared Helpers
Support\EventTitles - event labels for rows
Any row that references an event by id needs to display something for it. Eventonomy\Support\EventTitles is the one place that answers that, split into three layers so each surface takes only the layer it needs.
use Eventonomy\Support\EventTitles;
public static function map( array $rows, string $id_key = 'event_id' ): array;
public static function label( int $event_id, string $title ): string;
public static function label_from_map( int $event_id, array $events_map ): string;
public static function cell( int $event_id, array $events_map, string $url_override = '' ): string;
map()resolves title + permalink for a whole page of rows in one batched events query. Call it once before the row loop; a 100-row page then costs one extra query instead of 100. Returnsevent_id => [ 'title' => …, 'url' => … ], keyed only by ids it found.label()returns plain, unescaped text: the title, or#{id}when the title is missing (the event was deleted after the row was written) so the row still identifies itself.label_from_map()is the same thing sourced from amap()result.cell()returns escaped HTML - the label wrapped in an<a>, or escaped text when no URL is available, or—for a zero id.
The split is the point, and getting it wrong is a real bug. label() is the half that plain-text sinks may use - an admin notification email body, a CSV export. cell() emits markup and must never reach them; passing cell() output into an email body injects raw <a> tags into what the recipient reads. Conversely, label() output is unescaped, so any HTML sink must escape it at the point of output.
// Admin table: batch once, then render escaped cells.
$events_map = EventTitles::map( $rows ); // one query
foreach ( $rows as $row ) {
echo '<td>' . EventTitles::cell( (int) $row['event_id'], $events_map ) . '</td>'; // already escaped
}
// Link somewhere other than the public permalink, without reimplementing the fallback.
echo EventTitles::cell(
(int) $row['event_id'],
$events_map,
admin_url( 'admin.php?page=eventonomy-event-edit&id=' . (int) $row['event_id'] )
);
// Plain-text sink: never cell().
$subject = sprintf( 'New RSVP for %s', EventTitles::label_from_map( $event_id, $events_map ) );
Identity, names, time, and currency
Four helpers in includes/functions.php. All return unescaped values - escape at the point of output.
evnm_resolve_recipient_identity( array $row ): array; // { name: string, email: string }
evnm_user_display_names( array $user_ids ): array; // user_id => display_name
evnm_relative_time( int $timestamp, string $fallback = '' ): string;
evnm_resolve_currency_symbol( string $currency_code = '' ): string;
evnm_resolve_recipient_identity() resolves one row to a name and email. It reads guest_name, guest_email, and user_id (all optional). A typed guest value always wins; anything still empty is filled from the linked WP user account. Both keys are always present and always strings, possibly ''.
$who = evnm_resolve_recipient_identity( $rsvp_row );
wp_mail( $who['email'], $subject, $body );
evnm_user_display_names() is the no-N+1 batch helper for member names. The contract is precise:
- Exactly one
get_users( include )query for the whole set, regardless of size. - Zero queries when the input is empty, or contains only non-positive ids. Ids are cast to int, de-duplicated, and guest rows (
user_id0) are dropped before the query. - Unresolvable ids are absent from the returned map. A deleted user simply has no key. Callers must handle a miss - never assume every input id comes back:
$names = evnm_user_display_names( array_column( $rows, 'user_id' ) );
foreach ( $rows as $row ) {
$uid = (int) $row['user_id'];
$name = $names[ $uid ] ?? __( 'Unknown member', 'my-addon' ); // the miss is expected
echo esc_html( $name );
}
Only display names are selected - never emails. For a per-recipient name plus email, use evnm_resolve_recipient_identity().
evnm_relative_time() is the canonical "time until" label for an upcoming timestamp:
| Input | Output |
|---|---|
| Less than one whole day away (including exactly now) | Today |
| Exactly one whole day away | Tomorrow |
| Further out | in 3 days (via human_time_diff) |
| Past, or a zero/negative timestamp | $fallback (default '') |
Whole days are floored 24-hour spans measured from now. The returned string includes the in preposition, so a template writes …starts {relative} and nothing else.
// List surfaces: print nothing for past events.
$label = evnm_relative_time( $start_ts );
// Email: supply a word instead of silence.
$label = evnm_relative_time( $start_ts, __( 'soon', 'my-addon' ) );
evnm_resolve_currency_symbol() is the two-step resolution every money-rendering surface should use:
- The stored
currency_symbolsetting when the owner typed an explicit symbol - that always wins. - Otherwise the ISO 4217 map via
evnm_currency_symbol()(filterable throughevnm_currency_symbols).
Pass a code, or omit it to use the site's currency setting (default USD). Unknown codes fall back to the raw code, so the return is never empty.
echo esc_html( evnm_resolve_currency_symbol() ); // site currency
echo esc_html( evnm_resolve_currency_symbol( 'INR' ) ); // ₹
This helper deliberately layers on top of evnm_currency_symbol(): that one is the pure code-to-symbol map, this one adds the owner override. Anything rendering money for a human wants this function.
Internationalization
Translatable Event URL Slug (Polylang)
add_action( 'init', function () {
if ( function_exists( 'pll_register_string' ) ) {
pll_register_string( 'evnm_permalink_base', 'event', 'Eventonomy' );
}
} );
add_filter( 'evnm_permalink_base', function ( string $base ): string {
return function_exists( 'pll__' ) ? pll__( $base ) : $base;
} );
After adding this, flush rewrite rules (Settings → Permalinks → Save Changes).
WPML
add_action( 'init', function () {
if ( function_exists( 'icl_register_string' ) ) {
icl_register_string( 'Eventonomy', 'evnm_permalink_base', 'event' );
}
} );
add_filter( 'evnm_permalink_base', function ( string $base ): string {
return function_exists( 'icl_t' ) ? icl_t( 'Eventonomy', 'evnm_permalink_base', $base ) : $base;
} );
Deep Extension: Owning Your Own Tables
An add-on can own custom tables the same way Pro does - register a versioned migrator in a provider and bind a repository. Pro is the reference implementation and now owns four tables:
evnm_follows- follow relationships (organizer/event follows).evnm_earnings- the Model A commission ledger: one row per paid order recording the platform/organizer split.evnm_payout_debts- refund-after-payout netting, so an organizer who was already paid for a later-refunded order carries the debt to their next payout.evnm_pro_feeds- saved calendar-sync subscriptions (schema version 4). See Calendar Sync Internals for the columns, the repository contract, and the scheduler.
The earnings/payout model is the deepest worked example of the extension surface: it listens on the money-path hooks above (evnm_after_update_order to book/reverse earnings), exposes an operator CLI command (wp eventonomy-pro payout), and reads Free's evnm_format_money / order repository through the container, with zero Free concrete-class imports. Study eventonomy-pro/includes/{Core/Migrator.php,Repository/EarningsRepository.php,Providers/EarningsProvider.php} when building a table-backed add-on.
Worked End-to-End Example
A Vimeo virtual-event add-on, with no Eventonomy concrete-class imports:
- Feature flag -
add_filter('evnm_default_features', fn($f) => $f + ['vimeo' => true]). - Store the value - write
vimeo_urlevent meta withevnm_update_meta('event', $id, 'vimeo_url', …)from wherever your add-on captures it (your own admin/REST UI; there is no editor field-injection hook that auto-persists). - REST -
evnm_rest_prepare_eventreads the meta and adds it to the event payload; every block sees it. - Store - merge
state.vimeoEmbed+actions.openStreaminto theeventonomystore withstore('eventonomy', …). - Block - register via
evnm_blocks(only thepathkey);render.phpreadsevnm_get_view_data('event', $id)['event']['id'], thenevnm_get_meta(...), and wiresdata-wp-*.
Result: stored meta + REST exposure + frontend state + rendered block, using only hooks, the Meta API, the evnm_blocks filter, the shared store, and the canonical envelope.