Eventonomy

Recipe: Extend with a Provider

Goal: Add a service to Eventonomy's container using the auto-discovery provider pattern, the same pattern every Pro feature uses. The provider wires itself into the container with no changes to shared registrar files.

Seams Used

Seam Type Purpose
evnm_register_services Action Fires after the container loads all providers; your callback receives $container.
$container->bind() Method Replace the container's one binding for a contract. Later bindings win (Pro layers on Free this way).
$container->tag() Method Add an implementation to a collection (e.g. add a gateway alongside existing ones).
evnm() Function Public container accessor: resolve any bound contract anywhere.

When to Use bind() vs tag()

Method Use when
bind( Contract::class, MyImpl::class ) Replace the one implementation resolved by evnm( Contract::class ). E.g. swap a repository. Later bindings win.
tag( Contract::class, MyImpl::class ) Add to a collection that is consumed with evnm()->tagged( Contract::class ).

Only two contracts are actually consumed as tagged collections: NotificationChannelInterface (the notifier fans out to every tagged channel) and RecurrenceEngineInterface (the materializer dispatches to the first tagged engine whose handles_rule() returns true). Everything else in the container is a single bind().

Payment gateways are the exception to both. A gateway is not wired through the container; tagging PaymentGatewayInterface does not make a gateway appear. The checkout flow reads two filters instead: evnm_payment_gateways (advertise the gateway) and evnm_process_payment (run the charge). See the gateway example below.

Example: Adding a Payment Gateway (Filters, Not tag())

Implementing PaymentGatewayInterface gives you a clean object model, but registration is done with filters, exactly as Pro's Stripe/PayPal/Woo gateways do it.

<?php
// my-addon/includes/MyGateway.php
namespace MyAddon;

class MyGateway implements \Eventonomy\Contracts\PaymentGatewayInterface {
    public function id(): string { return 'my-gateway'; }
    public function label(): string { return 'Pay at Door'; }
    public function supported_currencies(): array { return [ 'USD', 'EUR' ]; }

    // charge() returns an array with 'transaction_id' and 'status' (or a WP_Error).
    public function charge( array $order, array $args ) {
        return [ 'transaction_id' => 'door-' . $order['order_number'], 'status' => 'paid' ];
    }

    public function capture( string $transaction_id, array $args ) {
        return [ 'transaction_id' => $transaction_id, 'status' => 'paid' ];
    }

    // Real signature: refund( string, int, string ): array|WP_Error.
    public function refund( string $transaction_id, int $amount_cents, string $currency = '' ) {
        return [ 'transaction_id' => $transaction_id, 'status' => 'refunded' ];
    }
}
<?php
// my-addon/my-addon.php (or a provider file)

// 1) Advertise the gateway so checkout knows a paid order can be taken.
add_filter( 'evnm_payment_gateways', function ( $gateways ) {
    $gateways            = (array) $gateways;
    $gateways['my-gateway'] = [ 'id' => 'my-gateway', 'label' => __( 'Pay at Door', 'my-addon' ) ];
    return $gateways;
} );

// 2) Actually run the charge when this gateway is selected. Return an array with
//    'transaction_id' (+ 'status'); return the prior $result if it's not yours.
add_filter( 'evnm_process_payment', function ( $result, $order, $input ) {
    if ( null !== $result ) {
        return $result; // Another gateway already handled it.
    }
    if ( 'my-gateway' !== ( $input['gateway'] ?? '' ) ) {
        return $result;
    }
    return ( new \MyAddon\MyGateway() )->charge( $order, $input );
}, 10, 3 );

Once both filters are wired, the gateway appears in the checkout selector and settles paid orders.

Example: Adding a Recurrence Engine (tag(), Not bind())

The recurrence engine is a tagged collection: Free tag()s its basic engine, and the materializer reads evnm()->tagged( RecurrenceEngineInterface::class ) and dispatches to the first engine whose handles_rule() returns true. So you add an engine with tag(); a bind() here would be ignored (nothing calls evnm( RecurrenceEngineInterface::class )).

add_action( 'evnm_register_services', function ( $container ): void {
    // tag(), not bind(): the materializer consumes the tagged collection.
    $container->tag(
        \Eventonomy\Contracts\RecurrenceEngineInterface::class,
        \MyAddon\MyRecurrenceEngine::class
    );
} );

MyRecurrenceEngine must implement the full interface:

  • handles(): array - the freq tokens it supports (e.g. [ 'DAILY', 'WEEKLY' ]).
  • handles_rule( array $rule ): bool - whether it can correctly expand this exact rule (return false for advanced parts it can't honour, so a more capable engine wins the dispatch).
  • expand( array $rule, \DateTimeImmutable $start, \DateTimeImmutable $window_end ): array - the concrete occurrences.

Using the Auto-Discovery Provider Pattern (Recommended)

The cleanest approach for plugins distributing multiple services: create a Providers/ directory and a file per provider. Eventonomy's own bootstrap scans includes/Providers/*.php via glob. You can mirror this in your plugin:

// my-addon/my-addon.php
foreach ( glob( plugin_dir_path( __FILE__ ) . 'includes/Providers/*.php' ) as $provider ) {
    require_once $provider;
}

Each provider file registers its own hook, for a tagged collection (notification channel or recurrence engine):

// my-addon/includes/Providers/MyChannelProvider.php
add_action( 'evnm_register_services', function ( $container ): void {
    $container->tag(
        \Eventonomy\Contracts\NotificationChannelInterface::class,
        \MyAddon\Services\SlackChannel::class
    );
} );

This pattern means adding a new service never requires editing a shared registrar, exactly how Pro adds features to Free. (A payment gateway provider registers the two filters from the gateway example above, not a tag().)

Resolving Services from the Container

// Single bound contract - evnm() returns the one implementation.
$repo = evnm( \Eventonomy\Contracts\EventRepositoryInterface::class );

// Tagged collection - use ->tagged(). Calling evnm() on a tag-only id THROWS
// (nothing is bound for it), so always resolve collections with tagged():
$channels = evnm()->tagged( \Eventonomy\Contracts\NotificationChannelInterface::class );
// -> array of every tagged NotificationChannelInterface implementation.

Never use new on a concrete class you don't own. Resolve contracts through evnm(): bind() a single contract, tagged() a collection. (Note: evnm() can resolve some concrete Services\* classes that are explicitly bound, e.g. evnm( \Eventonomy\Services\EventService::class ) for the documented EventService::create() entry point, but prefer interfaces everywhere else so a later swap doesn't break you.)

Verify It Worked

  1. Activate your add-on plugin.
  2. For a gateway: go to Settings → Money → Payments and confirm your gateway appears in the Active gateway dropdown (this proves both evnm_payment_gateways and evnm_process_payment are wired).
  3. For a tagged collection: write a small wp eval command that lists the resolved classes:
    wp eval 'var_dump( array_map( "get_class", evnm()->tagged( \Eventonomy\Contracts\RecurrenceEngineInterface::class ) ) );'
    

Related

  • docs/EXTENDING.md §3 - full Contract reference and container API.
  • docs/ARCHITECTURE.md - the auto-discovery backbone and provider loading.
  • Recipes Index