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:
| Constant | Value / meaning |
|---|---|
VAKANSSI_VERSION | The plugin version string, always equal to the Version header of the main plugin file. |
VAKANSSI_PLUGIN_FILE | Absolute path to the main plugin file. |
VAKANSSI_PLUGIN_BASENAME | plugin_basename() of the main file. |
VAKANSSI_PLUGIN_DIR | Absolute plugin directory path (trailing slash). |
VAKANSSI_PLUGIN_URL | Plugin directory URL (trailing slash). |
VAKANSSI_SRC_DIR | VAKANSSI_PLUGIN_DIR . 'src/', the PHP source root. |
VAKANSSI_MIN_PHP | Minimum supported PHP (7.4). |
VAKANSSI_MIN_WP | Minimum supported WordPress (6.6). |
VAKANSSI_UNLOCK_ALL | Premium 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:
- Constants are defined, then a custom PSR-4 autoloader maps the
Vakanssi\namespace tosrc/. There is no Composer build step. Composer is dev tooling only (phpcs, phpunit), and no third-party runtime dependencies ship in the free tier. - Procedural helpers (
vakanssi_table(),vakanssi_option(), and friends, all prefixedvakanssi_) load. In the premium build only, the Freemius hook-point loads next; the free build ships neither the file nor any reference to it. - The environment guard runs. If the host is unsupported, the plugin returns early.
- On
plugins_loadedat priority 5,vakanssi()->boot()runs once (the call is idempotent: a secondboot()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
initandadmin_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-tokenshandle); - 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 onvakanssi/modules/registerinstead (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:
| Module | Id | Tier | Required feature | Depends on |
|---|---|---|---|---|
| Core | core | Free (always on) | (none) | (none) |
| Jobs & Careers | jobs | Free (always on) | (none) | core |
| Application Intake | intake | Free | (none) | core, jobs |
| Applications | pipeline | Free | (none) | core, jobs |
| Notes | notes | Free | (none) | pipeline |
| Interviews | interviews | Free | (none) | pipeline |
| Email & Notifications | email | Free | (none) | core |
| Privacy & Data Retention | privacy | Free (always on) | (none) | core |
| Import | import | Free | (none) | jobs, pipeline |
| CSV Import | csv_import | Free | (none) | jobs |
| Scorecards | scorecards | Pro | scorecards | pipeline |
| Analytics | analytics | Pro | analytics | pipeline |
| Scheduling | scheduling | Pro | scheduling | pipeline, interviews |
| Sourcing & CRM | sourcing | Pro | sourcing | pipeline |
| Collaboration | collaboration | Pro | collaboration | pipeline, notes |
| Presence | presence | Pro | collaboration | pipeline |
| Screening Questions | screening | Pro | screening | pipeline, intake, jobs |
| Email Automation | email_automation | Pro | email_automation | email, pipeline |
| Integrations | integrations | Pro | integrations | pipeline |
| Advanced anti-spam | antispam | Pro | antispam | intake |
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:
- Enabled. Its toggle in the
vakanssi_modulesoption is on, or it is not togglable (core,jobs,privacy). Admins flip these on the Features tab under Hiring, then Tools. - Available. In the premium build, the
module_gateservice allows it: a Pro module passes only when the license grants itsrequired_feature(), and every other module always passes. The free build has no gate, so every module there is available. - 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:
VAKANSSI_UNLOCK_ALLdefined truthy, thenDevNullLicenseProvider(unlocks everything, for local dev and CI only);- a Freemius SDK instance is available (
vakanssi_fs(), or thevakanssi/license/freemius_instancefilter), thenFreemiusLicenseProviderwraps it; - otherwise
FreeLicenseProvider, whosecan()returnsfalsefor 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/tabsaction 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_panelsrenders your own panel (as a tab) inside the candidate drawer on the board. - Payload filter.
vakanssi/rest/application_payloadappends keys to the candidate-drawer REST response, so your data arrives with the drawer’s single request. - Authorization policy.
vakanssi/authz/canadds 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/createdandvakanssi/application/stage_changed, plusvakanssi/email/htmlandvakanssi/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.