Skip to content
W3Bullhorn
W3Bullhorn
Book a demo

Reference

Code examples

Five worked examples, each one complete enough to paste into a plugin of your own. They use only the published contract surface, so they keep working across releases that change the internals.

Example 1

An extension module

The way an add-on joins the plugin and gets hold of services and events.

use W3RP\Core\Contracts\Module\AbstractExtensionModule;
use W3RP\Core\Contracts\Module\ModuleContextInterface;
use W3RP\Core\Contracts\Module\ModuleRegistrarInterface;

final class Acme_Module extends AbstractExtensionModule {

    public function id(): string {
        return 'acme';
    }

    /** Same major, at least this minor. */
    public function contract_constraint(): string {
        return '^1.20';
    }

    public function register( ModuleContextInterface $context ): void {
        // Bind your own services. Nothing has booted yet.
    }

    public function boot( ModuleContextInterface $context ): void {
        // Everything is registered. Safe to subscribe and act.
    }
}

add_action(
    ModuleRegistrarInterface::HOOK,
    static function ( ModuleRegistrarInterface $registrar ): void {
        $registrar->register( new Acme_Module() );
    }
);

register() runs before anything boots; boot() runs once every module is registered and its dependencies are resolved. Declaring the contract constraint is what makes the plugin refuse to load your module against an incompatible release instead of letting it fail somewhere less obvious. Watch w3rp/module_rejected while you develop.

Example 2

Reacting to something happening

The event bus, from inside a module.

use W3RP\Core\Contracts\Lifecycle\ModulesBooted;
use W3RP\Core\Contracts\Module\ModuleContextInterface;

public function boot( ModuleContextInterface $context ): void {
    $context->events()->subscribe(
        ModulesBooted::class,
        static function ( ModulesBooted $event ): void {
            // Every module is up. Wire anything that needed them all.
        }
    );
}

ModulesBooted is dispatched once, after all modules have booted. A listener that throws is isolated — the exception does not escape into the request — and the failure is reported on w3rp/event_listener_failed, so a broken listener shows up instead of quietly doing nothing.

Example 3

An administrator REST route

Declared by you, registered and protected by the plugin.

use W3RP\Core\Contracts\Rest\AdminRequest;
use W3RP\Core\Contracts\Rest\AdminRoute;
use W3RP\Core\Contracts\Rest\AdminRouteRegistrarInterface;

add_action(
    AdminRouteRegistrarInterface::HOOK,
    static function ( AdminRouteRegistrarInterface $routes ): void {

        $arguments = array(
            'label' => array(
                'type'      => 'string',
                'format'    => 'text-field',
                'maxLength' => 64,
                'required'  => true,
            ),
        );

        $route = AdminRoute::create(
            'acme',
            '/label',
            'POST',
            $arguments,
            static function ( AdminRequest $request ): array {
                // Access is already decided. Just do the work.
                return array( 'saved' => $request->argument( 'label' ) );
            }
        );

        if ( null === $route ) {
            // Find out why rather than shipping a route that is absent.
            error_log( (string) AdminRoute::refusal( 'acme', '/label', 'POST', $arguments ) );
            return;
        }

        $routes->register( $route );
    }
);

The route is served at w3rp/v1/admin/ext/acme/label. Read the address from $route->rest_route() rather than building the string, because the prefix is the plugin’s to decide. Note the refusal check: a declaration is only validated when it is registered, so a schema the plugin refuses leaves your route missing while your own tests stay green.

Example 4

Changing the markup of a job card

The common case, and it needs no PHP at all.

# Copy the template into your child theme and edit it there.
mkdir -p wp-content/themes/acme-child/w3bullhorn/parts

cp wp-content/plugins/w3rp-core/templates/parts/job-card.php \
   wp-content/themes/acme-child/w3bullhorn/parts/job-card.php

That is the whole mechanism: the active theme is checked first, then its parent, then the plugin. Nothing needs registering and nothing needs a filter. Keep the file as close to the original as you can — a copy that has diverged heavily is the thing that makes upgrading painful later.

Example 5

Adding a field to a job page

A contributor can add rows. It cannot remove or reorder what is already shown.

use W3RP\Core\Contracts\Job\JobFieldContributorRegistrarInterface;

add_action(
    JobFieldContributorRegistrarInterface::HOOK,
    static function ( JobFieldContributorRegistrarInterface $fields ): void {
        $fields->register( new Acme_Field_Contributor() );
    }
);

The plugin asks each registered contributor once per job it renders, and only on a request that actually renders a job. Your contributor is handed a context holding only the values the plugin is willing to show, and returns field requests; a request for a field that is not currently permitted is declined and reported on w3rp/job_field_contribution_rejected. That asymmetry is deliberate: an add-on can enrich a job page but cannot take a field off it, so no add-on can quietly remove a disclosure the agency chose to make.

Before you ship

Two rules worth repeating

  • Import only from W3RP\Core\Contracts. Everything else is internal and may change in any release, including a patch. If an example here reaches outside that namespace, it is a bug in the example.
  • Test your registration against the real plugin. Registrations are validated at the moment they are registered. A test suite that never loads the plugin cannot tell you that your module, route or field was refused.

Put your Bullhorn jobs on your own website

See W3Bullhorn working with your own Bullhorn jobs, on a short call with a specialist who knows Bullhorn and WordPress.

Book a demo How it works