Skip to content
VakanssiDocs
VakanssiDocs
Get Vakanssi
esc

Architecture

How Vakanssi boots: the PSR-4 autoloader, service container, module registry, the free and premium builds, the data layer, the vakanssi/v1 REST API, and the extension seams built into the free tier.

On this page(11)

Vakanssi is a modular WordPress plugin: a small core (service container, security layer, data layer, REST server) plus a set of feature modules that register their own hooks, screens, routes, and assets only when they boot.

One codebase produces two builds. The free build, published on WordPress.org, contains no Pro code at all. The premium build of Vakanssi Pro Pro feature, requires a Vakanssi Pro license contains everything, and its Pro modules boot only while the license allows them. See Free and premium builds.

This page is the orientation map. The details live on Hooks, REST API, Capabilities & roles, Database schema, and Extending safely.

Requirements and constants

Vakanssi requires WordPress 6.6+ (since v0.37.11, was 6.4+) and PHP 7.4+. An environment guard checks both minimums at load time; below them the plugin refuses to boot and shows an admin notice instead of fataling, and activation is blocked outright.

The main file (vakanssi.php) defines the constants the rest of the codebase relies on:

ConstantValue / meaning
VAKANSSI_VERSIONThe plugin version string, always equal to the Version header of the main plugin file.
VAKANSSI_PLUGIN_FILEAbsolute path to the main plugin file.
VAKANSSI_PLUGIN_BASENAMEplugin_basename() of the main file.
VAKANSSI_PLUGIN_DIRAbsolute plugin directory path (trailing slash).
VAKANSSI_PLUGIN_URLPlugin directory URL (trailing slash).
VAKANSSI_SRC_DIRVAKANSSI_PLUGIN_DIR . 'src/', the PHP source root.
VAKANSSI_MIN_PHPMinimum supported PHP (7.4).
VAKANSSI_MIN_WPMinimum supported WordPress (6.6).
VAKANSSI_UNLOCK_ALLPremium build only, optional. Define truthy in wp-config.php to unlock every Pro module for local development and CI (see Free and premium builds).

Boot sequence

Everything starts in vakanssi.php:

  1. Constants are defined, then a custom PSR-4 autoloader maps the Vakanssi\ namespace to src/. There is no Composer build step. Composer is dev tooling only (phpcs, phpunit), and no third-party runtime dependencies ship in the free tier.
  2. Procedural helpers (vakanssi_table(), vakanssi_option(), and friends, all prefixed vakanssi_) load. In the premium build only, the Freemius hook-point loads next; the free build ships neither the file nor any reference to it.
  3. The environment guard runs. If the host is unsupported, the plugin returns early.
  4. On plugins_loaded at priority 5, vakanssi()->boot() runs once (the call is idempotent: a second boot() is a no-op).

Plugin::boot() then does the real wiring, in order:

  • registers the core services in the container (all lazy: nothing is constructed yet);
  • hooks a self-healing upgrade check on both init and admin_init (maybe_upgrade()), so schema, roles, and version metadata re-sync after a plugin file update without reactivation, even before an admin next loads wp-admin;
  • registers REST routes on rest_api_init (there is no manual text-domain load: WordPress loads the bundled translations on first use);
  • on multisite, hooks site deletion so a deleted site’s Vakanssi tables and CV files go with it, provided Vakanssi is loaded where the deletion runs (network-activated, or active on the main site when you delete from the network admin);
  • registers the brand design tokens (registered, never enqueued, so any surface can depend on the vakanssi-tokens handle);
  • asks the module registry to discover all modules and boot the active ones, in dependency order;
  • registers stage-event maintenance on every request type: in the premium build it records each application’s creation and stage moves in an append-only log for Pro Analytics Pro feature, requires a Vakanssi Pro license; in both builds it purges an application’s events when the application is deleted;
  • registers the admin shell, onboarding wizard, sample-content tools, and Site Health checks only when is_admin();
  • fires vakanssi/booted. The plugin documents it as the earliest stable hook for registering services or REST routes. Modules are the exception: discovery has already run by then, so register a module on vakanssi/modules/register instead (see The module system).

The autoloader itself is deliberately tiny:

// Maps a namespace prefix to a base directory and lazily requires class files,
// in place of Composer's autoloader so the plugin runs on hosts with no build step.
( new \Vakanssi\Support\Autoloader( 'Vakanssi\\', VAKANSSI_SRC_DIR ) )->register();

The service container

Core services live in a small, PSR-11-shaped dependency-injection container. It supports transient bindings (bind()), shared singletons (singleton()), pre-built instances (instance()), plus has() and get(). get() throws a RuntimeException for an unknown id. Factories receive the container so they can resolve their own dependencies:

// Everything is a lazily-resolved singleton, so nothing is constructed
// until first use, keeping the boot path cheap.
$c->singleton( 'capabilities', static function () {
    return new Capabilities();
} );

$c->singleton( 'modules', static function ( Container $c ) {
    // Factories receive the container to resolve their own dependencies.
    return new ModuleRegistry( $c );
} );

// Resolution: constructed on the first get(), cached for the rest of the request.
$modules = vakanssi()->get( 'modules' );

The core service ids in both builds are plugin, capabilities, roles, authz, migrator, storage, audit, modules, rest, admin, and settings_tabs. The premium build also registers license and module_gate; the free build has neither, so has( 'license' ) is false there.

The container is internal

The container class is explicitly marked @internal. Service ids and signatures may change without a major-version bump. Extensions should build on the public hooks instead; see Extending safely.

The module system

Every feature is a module implementing ModuleInterface: id(), label(), description(), dependencies(), is_default_active(), is_togglable(), and boot( Container $container ). Most modules extend AbstractModule, whose defaults describe a togglable, dependency-free, on-by-default module, so a concrete module only overrides what it needs.

A module is Pro exactly when it implements Vakanssi\Licensing\GatedModuleInterface, whose one method, required_feature(), returns its feature slug. That interface exists only in the premium build, and so do the Pro modules. The build script’s PRO_MODULES list is checked against these declarations, so the two cannot drift apart. These are the built-in modules:

ModuleIdTierRequired featureDepends on
CorecoreFree (always on)(none)(none)
Jobs & CareersjobsFree (always on)(none)core
Application IntakeintakeFree(none)core, jobs
ApplicationspipelineFree(none)core, jobs
NotesnotesFree(none)pipeline
InterviewsinterviewsFree(none)pipeline
Email & NotificationsemailFree(none)core
Privacy & Data RetentionprivacyFree (always on)(none)core
ImportimportFree(none)jobs, pipeline
CSV Importcsv_importFree(none)jobs
ScorecardsscorecardsProscorecardspipeline
AnalyticsanalyticsProanalyticspipeline
SchedulingschedulingProschedulingpipeline, interviews
Sourcing & CRMsourcingProsourcingpipeline
CollaborationcollaborationProcollaborationpipeline, notes
PresencepresenceProcollaborationpipeline
Screening QuestionsscreeningProscreeningpipeline, intake, jobs
Email Automationemail_automationProemail_automationemail, pipeline
IntegrationsintegrationsProintegrationspipeline
Advanced anti-spamantispamProantispamintake

Since v0.35.1, Privacy & Data Retention (renamed from Privacy & Compliance in v0.37.11) is always on (not togglable), like core and jobs. In the free build the registry registers only the ten free modules; the premium build adds whichever Pro module classes are present on disk.

The registry (ModuleRegistry) decides what boots. A module is active only when all three hold:

  1. Enabled. Its toggle in the vakanssi_modules option is on, or it is not togglable (core, jobs, privacy). Admins flip these on the Features tab under Hiring, then Tools.
  2. Available. In the premium build, the module_gate service allows it: a Pro module passes only when the license grants its required_feature(), and every other module always passes. The free build has no gate, so every module there is available.
  3. Dependencies active. Every module it depends on is itself active.

Active modules boot in dependency (topological) order, and vakanssi/modules/loaded fires when they are done. A disabled or unavailable module’s boot() is simply never called, so it registers no hooks, admin screens, REST routes, cron events, or assets.

Resolution is cycle-safe (a dependency cycle is reported and the offending modules are disabled rather than recursing into a stack overflow) and duplicate ids are rejected (first registration wins). Each module’s status is captured in an immutable ModuleState recording why it is inactive: admin intent (disabled) takes precedence over unavailable, which takes precedence over a dependency problem, so a stored toggle is never masked by a license lapse.

Boot is fault-isolated: if an active module’s boot() throws, the registry catches it, logs it, and moves on to the next module instead of taking the rest of the site down.

Anyone holding vakanssi_manage_settings sees an admin notice (“The Vakanssi module X failed to load and was disabled for this request”) on every wp-admin screen until the underlying error is fixed, since a single broken module can otherwise affect unrelated parts of the site. See Troubleshooting for what to do when you see it.

Third-party modules register on the vakanssi/modules/register action and participate in the exact same lifecycle, in both builds. The action fires during discovery, inside the registry’s load() on plugins_loaded priority 5, so add your callback when your plugin’s main file loads. A throwing callback on this hook is isolated the same way: caught, logged, and the rest of module discovery still completes.

use Vakanssi\Core\Container;
use Vakanssi\Core\Modules\AbstractModule;

add_action( 'vakanssi/modules/register', function ( $registry ) {
    $registry->register( new class() extends AbstractModule {
        public function id() { return 'acme_referrals'; }
        public function label() { return __( 'Referrals', 'acme-hiring' ); }
        public function description() { return __( 'Employee referral tracking.', 'acme-hiring' ); }
        public function dependencies() { return array( 'pipeline' ); }
        public function boot( Container $container ) {
            add_action( 'vakanssi/application/created', 'acme_record_referral', 10, 3 );
        }
    } );
} );

The anonymous class keeps the example to one file; a real extension would normally declare its own named class. acme_record_referral() stands for your own callback. Because the module depends on pipeline, it boots after it, and it stays off whenever Applications is switched off.

Free and premium builds

One codebase produces two zips. The free build goes to WordPress.org. Every Pro module, the whole src/Licensing/ directory, Pro-only assets, and every block marked /* <premium-only> */ in shared files are removed from it. The premium build keeps everything and is delivered by Freemius: buying Pro updates the site to that build, which is when Pro code first reaches it.

So the free build has no license checks, no locked or upsell cards, and no Pro modules to discover. Its module registry knows only the free modules, every one of them is available, and only the admin toggle and the dependency graph decide what boots. A Pro REST route does not exist there, so a request to one returns WordPress’s standard rest_no_route 404.

The free build creates 10 custom tables; the premium build creates 14, adding evaluations, candidate_tags, webhook_deliveries, and presence. A site moved down from premium keeps those four tables, with data.

When Delete all data on uninstall is on, the free build’s uninstall still finds and drops all four by table name. Otherwise it only partly handles them: deleting an application still removes its evaluations rows, but it does not export tags, evaluations, or screening answers, remove a candidate’s tags on erasure, or age out webhook payloads.

The license layer (premium build only)

In the premium build, Plugin registers two extra services: license (LicenseManager::create()) and module_gate (LicenseModuleGate). The registry asks the gate whether each module is available; the gate allows any module that does not implement GatedModuleInterface, and asks the license provider’s can( $feature ) for one that does.

There are nine feature slugs: analytics, antispam, collaboration, email_automation, integrations, scheduling, scorecards, screening, and sourcing. Each matches its module id, with one exception: Presence (presence) returns collaboration, so it rides the Collaboration license. That is why ten Pro modules unlock on nine slugs.

LicenseManager::create() resolves the provider in this order:

  1. VAKANSSI_UNLOCK_ALL defined truthy, then DevNullLicenseProvider (unlocks everything, for local dev and CI only);
  2. a Freemius SDK instance is available (vakanssi_fs(), or the vakanssi/license/freemius_instance filter), then FreemiusLicenseProvider wraps it;
  3. otherwise FreeLicenseProvider, whose can() returns false for every feature.

The resolved provider is filterable through vakanssi/license/provider; a return that does not implement LicenseProvider falls back to FreeLicenseProvider. The Freemius adapter’s per-feature answer is itself filterable via vakanssi/license/can. These hooks, and the classes above, do not exist in the free build.

// Return any object implementing Vakanssi\Licensing\LicenseProvider.
add_filter( 'vakanssi/license/provider', function ( $provider ) {
    return new \Acme\Hiring\CustomKeyLicenseProvider();
} );

See Free vs Pro for the user-facing view.

Data layer

High-volume hiring data lives in dedicated, indexed custom tables (wp_vakanssi_applications, wp_vakanssi_candidates, wp_vakanssi_stage_events, and more: 10 in the free build, 14 in the premium build) rather than posts and postmeta, the same reasoning behind WooCommerce’s High-Performance Order Storage, so the system stays fast as applications climb into the tens of thousands.

Tables are read and written through repository classes over $wpdb (ApplicationRepository, CandidateRepository, StageEventRepository, and friends, all extending a shared Repository base) with type-safe writes and automatic timestamp stamping; hot queries (the inbox, the board) join only these tables.

Tables are created and upgraded by dbDelta() migrations that the Migrator self-guards on a stored schema revision, checked on both init and admin_init (maybe_upgrade()) so a stalled migration keeps retrying rather than getting stuck, on a front-end or REST request as well as an admin visit.

The stored schema version only advances once every expected table is confirmed to exist after dbDelta() runs; if a table failed to create (a database permission or storage limit, for example), the version is left alone so the next request retries instead of the site believing it is already up to date. Job postings themselves are a regular custom post type (vakanssi_job) in wp_posts.

Full column-by-column detail is on the Database schema page.

The vakanssi/v1 REST API

Interactive admin surfaces (the applications board, the candidate drawer) talk to REST routes under the vakanssi/v1 namespace (AbstractController::NAMESPACE). The Server registers the health controller itself and then fires vakanssi/rest/register_routes, passing itself and the container.

Every module registers its own controllers on that hook (Applications, Notes, and Interviews among the free ones). The hook is marked Internal in the plugin’s hook inventory, so an extension that uses it, as below, accepts that it may change:

add_action( 'vakanssi/rest/register_routes', function ( $server, $container ) {
    ( new \Acme\Hiring\Rest\ReferralsController( $container ) )->register();
}, 10, 2 );

This is why a switched-off module’s endpoints 404 cleanly: an unbooted module never adds its callback to this hook, so its routes are never registered. In the free build the Pro modules are absent altogether. The full route inventory is on the REST API page.

Extension seams in the free tier

Several seams ship in the free tier specifically so extensions (including the Pro modules themselves) can attach without patching core:

  • Settings-tab registry. The vakanssi/settings/tabs action hands you the registry so you can add a tab to the one tabbed Settings screen.
  • Drawer panel slot. The JavaScript filter vakanssi.pipeline.drawer_panels renders your own panel (as a tab) inside the candidate drawer on the board.
  • Payload filter. vakanssi/rest/application_payload appends keys to the candidate-drawer REST response, so your data arrives with the drawer’s single request.
  • Authorization policy. vakanssi/authz/can adds object-level permission scoping on top of capabilities, without re-plumbing call sites. Its collection-level companion, vakanssi/authz/application_scope, fires only in the premium build Pro feature, requires a Vakanssi Pro license.
  • Lifecycle actions and email filters. Actions such as vakanssi/application/created and vakanssi/application/stage_changed, plus vakanssi/email/html and vakanssi/email/headers.

Working examples for each are on Extending safely.

Admin UI approach

Admin screens are server-rendered WordPress admin pages (menus, list tables, Settings API forms) with React islands only where interactivity earns them. The applications board is the flagship island: a build-free React app on WordPress’s bundled packages (wp-element, wp-api-fetch, wp-components, wp-i18n, wp-hooks) that ships from source with no bundler and talks to the vakanssi/v1 REST routes.

Assets are enqueued only on the plugin’s own screens and cache-busted by file modification time, so a source edit reaches the browser without a version bump.

Versioning and the v1 contract

The extension contract is the hook reference: hooks marked Public are the v1 contract, with frozen names, argument order, and semantics and a documented deprecation policy. Hooks marked Internal are implementation detail and may change or disappear in any release. Filters must return a value of the same type they receive; actions are notifications whose return values are ignored.

Everything not in the public contract (the container, repository classes, module internals, admin markup, JS internals beyond the documented slot) is private API. The full annotated inventory is on the Hooks reference, and the ground rules for building on it are on Extending safely.