A standalone WordPress PHP toolkit providing DataLayer, Cache, Migration, Admin Notification, and Settings components. Works with any WordPress plugin — no WooCommerce dependency required.
Package: wedevs/wp-kit
Namespace: WeDevs\WPKit\
Requires: PHP 7.4+
This guide serves two audiences:
Plugin Authors — You are building a WordPress plugin and want to use WPKit for your data models, database migrations, caching, admin notifications, and schema-driven settings pages. You will extend WPKit's base classes, configure prefixes, and wire everything together in your plugin bootstrap.
Third-Party Developers — You are extending a plugin that already uses WPKit (e.g., building an add-on for Dokan, or customizing behavior via hooks). You need to understand the hook system, how to filter model data, modify SQL queries, inject custom notices, and work with the existing data layer without breaking things.
- Installation
- Quick Start (Plugin Authors)
- Cache
- DataLayer
- Migration
- Admin Notifications
- Settings
- Extending a WPKit-Based Plugin (Third-Party Guide)
- Hook Reference
Add the package via Composer:
composer require wedevs/wp-kitOr reference it in your plugin's composer.json:
{
"require": {
"wedevs/wp-kit": "^1.0"
}
}All classes autoload via PSR-4 under the WeDevs\WPKit\ namespace.
A minimal example wiring up all four components in a plugin bootstrap:
use WeDevs\WPKit\DataLayer\DataLayerFactory;
use WeDevs\WPKit\Migration\MigrationRegistry;
use WeDevs\WPKit\Migration\MigrationManager;
use WeDevs\WPKit\Migration\MigrationHooks;
use WeDevs\WPKit\Migration\MigrationRESTController;
use WeDevs\WPKit\AdminNotification\NoticeManager;
use WeDevs\WPKit\AdminNotification\NoticeRESTController;
// 1. DataLayer
DataLayerFactory::init( 'myplugin' );
DataLayerFactory::register_store( Order::class, OrderStore::class );
// 2. Migration
$registry = new MigrationRegistry( 'myplugin_db_version', MYPLUGIN_VERSION );
$registry->register_many( [
'1.0.0' => V_1_0_0::class,
'1.1.0' => V_1_1_0::class,
] );
$manager = new MigrationManager( $registry, 'myplugin' );
( new MigrationHooks( $manager, 'myplugin' ) )->register();
// 3. Admin Notifications
$notices = new NoticeManager( 'myplugin' );
// 4. REST API (migration + notifications)
add_action( 'rest_api_init', function () use ( $manager, $notices ) {
( new MigrationRESTController( $manager, 'myplugin/v1' ) )->register_routes();
( new NoticeRESTController( $notices, 'myplugin/v1' ) )->register_routes();
} );Important: Every component requires a consumer-provided prefix. WPKit never generates hook names, option keys, or cache keys on its own — your plugin controls the namespace entirely.
WPKit provides a layered caching system inspired by WooCommerce's src/Caching/ architecture. It works with any WordPress object cache backend (Redis, Memcached, APCu, or the default in-memory cache).
CacheEngineInterface (contract)
└── WPCacheEngine (wp_cache_* wrapper, uses CacheNameSpaceTrait)
└── ObjectCache (typed cache for specific object types)
The low-level contract for cache operations:
use WeDevs\WPKit\Cache\Contracts\CacheEngineInterface;
interface CacheEngineInterface {
public function get( string $key, string $group = '' );
public function get_many( array $keys, string $group = '' ): array;
public function set( string $key, $value, string $group = '', int $expiration = 0 ): bool;
public function set_many( array $items, string $group = '', int $expiration = 0 ): array;
public function delete( string $key, string $group = '' ): bool;
public function exists( string $key, string $group = '' ): bool;
public function flush_group( string $group = '' ): bool;
}You can implement this interface to provide a custom cache backend (e.g., a file-based cache or a third-party service). Pass your implementation to ObjectCache or use it directly.
The default engine wrapping WordPress wp_cache_* functions. Requires a consumer-provided prefix to namespace all cache keys:
use WeDevs\WPKit\Cache\WPCacheEngine;
$engine = new WPCacheEngine( 'myplugin' );
// Basic operations
$engine->set( 'user_42', $data, 'myplugin_users', 3600 );
$value = $engine->get( 'user_42', 'myplugin_users' );
$engine->delete( 'user_42', 'myplugin_users' );
// Bulk operations
$engine->set_many( [ 'key1' => $val1, 'key2' => $val2 ], 'group', 3600 );
$results = $engine->get_many( [ 'key1', 'key2' ], 'group' );
// Group invalidation (works with distributed caches)
$engine->flush_group( 'myplugin_users' );Used internally by WPCacheEngine. Implements microtime-based namespace prefixing for group-level invalidation that works reliably with distributed caches (Redis, Memcached) where wp_cache_flush_group() may not exist.
When flush_group() is called, a new microtime prefix is generated, making all previously cached keys inaccessible without physically deleting them. Old entries are naturally evicted by the cache backend's memory management.
If you build a custom CacheEngineInterface implementation and want group invalidation, use this trait and implement get_cache_key_prefix(): string.
A higher-level typed cache for specific object types (models, entities). Used automatically by BaseDataStore when a model defines $cache_group:
use WeDevs\WPKit\Cache\ObjectCache;
$cache = new ObjectCache( $engine, 'myplugin_orders', HOUR_IN_SECONDS );
// Cache an object
$cache->set( $order_data, 42 );
// Retrieve — returns null on miss
$order = $cache->get( 42 );
// Retrieve with lazy-loading callback (called only on cache miss)
$order = $cache->get( 42, function () {
return load_order_from_db( 42 );
} );
// Bulk operations
$orders = $cache->get_many( [ 1, 2, 3 ] );
// Invalidation
$cache->remove( 42 ); // Single object
$cache->flush(); // Entire object type
// Status
$cache->is_cached( 42 ); // boolExpiration constants:
ObjectCache::DEFAULT_EXPIRATION— uses the value passed to the constructorObjectCache::MAX_EXPIRATION—MONTH_IN_SECONDS
The DataLayer provides a Model + DataStore pattern for database CRUD operations using $wpdb directly, with no WooCommerce dependency.
Extend BaseModel to define your entity. Models handle property management, change tracking, type casting, and hook firing.
use WeDevs\WPKit\DataLayer\Model\BaseModel;
class Order extends BaseModel {
protected string $object_type = 'order';
protected string $hook_prefix = 'myplugin_';
protected string $cache_group = 'myplugin_orders';
protected array $data = [
'customer_id' => 0,
'total' => 0.00,
'status' => 'pending',
'created_at' => null,
];
protected array $casts = [
'customer_id' => 'int',
'total' => 'float',
'created_at' => 'date',
];
// --- Getters ---
public function get_customer_id( string $context = 'view' ): int {
return $this->get_prop( 'customer_id', $context );
}
public function get_total( string $context = 'view' ): float {
return $this->get_prop( 'total', $context );
}
public function get_status( string $context = 'view' ): string {
return $this->get_prop( 'status', $context );
}
public function get_created_at( string $context = 'view' ) {
return $this->get_prop( 'created_at', $context );
}
// --- Setters ---
public function set_customer_id( int $id ): void {
$this->set_prop( 'customer_id', $id );
}
public function set_total( float $total ): void {
$this->set_prop( 'total', $total );
}
public function set_status( string $status ): void {
$this->set_prop( 'status', $status );
}
public function set_created_at( $date ): void {
$this->set_date_prop( 'created_at', $date );
}
}| Property | Type | Description |
|---|---|---|
$object_type |
string |
Identifier used in hook names (e.g., 'order') |
$hook_prefix |
string |
Plugin prefix for hooks (e.g., 'myplugin_'). Must be set. |
$data |
array |
Default property values. Defines the model's schema. |
| Property | Type | Description |
|---|---|---|
$cache_group |
string |
Cache group name. Enables automatic caching in DataStore. |
$casts |
array |
Type casting map. Supported: int, float, string, bool, date, array |
Models use a getter/setter pattern with two contexts:
get_prop( $prop, $context )— When$contextis'view'(default), the value passes throughapply_filters()and type casting. When'edit', returns the raw stored value.set_prop( $prop, $value )— Beforeset_object_read(), writes directly to$data. After (i.e., for existing objects loaded from DB), writes to$changesfor change tracking.set_date_prop( $prop, $value )— Accepts strings ('2024-01-15 10:30:00'), Unix timestamps,DateTimeInterfaceobjects, ornull.set_props( $props )— Bulk setter. For each key, callsset_{$key}()if that method exists.
$order = new Order( 42 );
// ... load from DB via data store ...
$order->set_status( 'completed' );
$order->is_dirty(); // true — has uncommitted changes
$order->is_dirty( 'status' ); // true
$order->is_dirty( 'total' ); // false
$order->get_changes(); // ['status' => 'completed']
$order->apply_changes(); // Merges changes into $data, clears $changes// Create
$order = new Order();
$order->set_customer_id( 5 );
$order->set_total( 99.99 );
$order->save(); // Inserts to DB, returns the new ID
// Read (via DataStore — see below)
// Update
$order = new Order( 42 );
// ... load from DB ...
$order->set_status( 'shipped' );
$order->save(); // Updates existing record
// Delete
$order->delete();
// Bulk delete (static)
Order::delete_by( [ 'status' => 'cancelled' ] );These hooks fire during save() and delete():
| Hook | Type | When |
|---|---|---|
{hook_prefix}before_{object_type}_save |
action | Before create or update |
{hook_prefix}after_{object_type}_save |
action | After create or update |
{hook_prefix}pre_delete_{object_type} |
filter | Before delete — return non-null to short-circuit |
Example with hook_prefix = 'dokan_' and object_type = 'vendor_balance':
dokan_before_vendor_balance_savedokan_after_vendor_balance_savedokan_pre_delete_vendor_balance
Models provide static methods for querying records directly — no need to manually interact with data stores. These methods return hydrated model instances that are fully functional (you can call save(), delete(), etc. on them).
// Find by ID — returns the model or null if not found
$task = Task::find( 42 );
if ( $task ) {
echo $task->get_title();
$task->set_status( 'completed' );
$task->save();
}Returns a QueryResult object containing hydrated models with pagination metadata:
use WeDevs\WPKit\DataLayer\QueryResult;
// Basic pagination
$result = Task::query( [
'per_page' => 10,
'page' => 2,
] );
foreach ( $result as $task ) {
echo $task->get_title();
}
echo $result->total(); // 55 total matching rows
echo $result->total_pages(); // 6
echo $result->current_page(); // 2
echo $result->has_more(); // true
// Filter by field values
$result = Task::query( [
'status' => 'pending',
'per_page' => 20,
'orderby' => 'created_at',
'order' => 'ASC',
] );
// Search across searchable fields (defined in DataStore)
$result = Task::query( [
'search' => 'urgent',
'per_page' => 10,
] );
// Comparison operators
$result = Task::query( [
'priority__lte' => 5,
'status__in' => [ 'pending', 'in_progress' ],
] );
// Date ranges
$result = Task::query( [
'date_query' => [
'column' => 'due_date',
'after' => '2025-01-01',
'before' => '2025-12-31',
],
] );Returns an array of models (no pagination limit by default):
// All tasks
$tasks = Task::all();
// All pending tasks
$tasks = Task::all( [ 'status' => 'pending' ] );
// With ordering
$tasks = Task::all( [ 'orderby' => 'priority', 'order' => 'ASC' ] );// Total tasks
$total = Task::count();
// Count by criteria
$pending = Task::count( [ 'status' => 'pending' ] );QueryResult is an iterable and countable wrapper returned by Model::query(). It holds both the model instances and pagination metadata.
$result = Task::query( [ 'status' => 'pending', 'per_page' => 10, 'page' => 1 ] );| Method | Returns | Description |
|---|---|---|
items() |
array |
Array of model instances on the current page |
total() |
int |
Total matching rows (before pagination) |
per_page() |
int |
Items per page |
current_page() |
int |
Current page number |
total_pages() |
int |
Total number of pages |
has_more() |
bool |
Whether more pages exist after the current one |
is_empty() |
bool |
Whether the result set is empty |
| Method | Returns | Description |
|---|---|---|
first() |
?Model |
First item, or null if empty |
last() |
?Model |
Last item, or null if empty |
pluck( $method ) |
array |
Call a getter on each item and collect results |
to_array() |
array |
Convert to ['items', 'total', 'per_page', 'current_page', 'total_pages'] |
count() |
int |
Number of items on the current page (PHP count() compatible) |
QueryResult implements IteratorAggregate and Countable, so you can use it directly in foreach and count():
$result = Task::query( [ 'status' => 'pending' ] );
// Iterate directly
foreach ( $result as $task ) {
echo $task->get_title();
}
// Count items on current page
echo count( $result ); // e.g., 10
// Pluck values
$titles = $result->pluck( 'get_title' );
// ['Task 1', 'Task 2', ...]WPKit provides a standalone DateTime class extending PHP's DateTimeImmutable (replaces WC_DateTime):
use WeDevs\WPKit\DataLayer\DateTime;
// Create from database string
$dt = DateTime::from_db_string( '2024-01-15 10:30:00' );
// Create from Unix timestamp
$dt = DateTime::from_timestamp( 1705312200 );
// Format for database storage
$db_string = $dt->to_db_string(); // 'Y-m-d H:i:s' format
// Standard DateTimeImmutable methods work
$dt->format( 'Y-m-d' ); // '2024-01-15'Immutability prevents the mutation bugs that WC_DateTime (mutable) was susceptible to.
Extend BaseDataStore to define how a model maps to a database table. Data stores handle all SQL operations through $wpdb.
use WeDevs\WPKit\DataLayer\DataStore\BaseDataStore;
class OrderStore extends BaseDataStore {
public function get_table_name(): string {
return 'myplugin_orders'; // Without $wpdb->prefix
}
protected function get_fields_with_format(): array {
return [
'customer_id' => '%d',
'total' => '%f',
'status' => '%s',
'created_at' => '%s',
];
}
}| Method | Returns | Description |
|---|---|---|
get_table_name() |
string |
Table name without $wpdb->prefix (prefix is added automatically) |
get_fields_with_format() |
array |
Map of column_name => wpdb_format (%d, %s, %f) |
| Method | Default | Description |
|---|---|---|
get_id_field_name() |
'id' |
Primary key column name |
get_id_field_format() |
'%d' |
Primary key format for wpdb::prepare() |
get_date_format_for_field( $field ) |
'Y-m-d H:i:s' |
PHP date format for DateTimeInterface → string conversion per column |
$store = new OrderStore();
// Create — inserts row, sets model ID
$store->create( $order );
// Read — checks cache first, then DB. Populates model properties.
$store->read( $order );
// Update — updates changed fields, invalidates cache
$store->update( $order );
// Delete — removes row, flushes cache group
$store->delete( $order );
// Conditional delete — returns number of affected rows
$store->delete_by( [ 'status' => 'cancelled', 'customer_id' => 5 ] );
// Conditional update — returns number of affected rows
$store->update_by(
[ 'status' => 'pending' ], // WHERE conditions
[ 'status' => 'cancelled' ] // SET values
);| Hook | Type | Description |
|---|---|---|
{hook_prefix}insert_data |
filter | Modify data array before wpdb::insert() |
{hook_prefix}insert_data_format |
filter | Modify format array before wpdb::insert() |
{hook_prefix}after_insert |
action | After wpdb::insert() completes |
{hook_prefix}created |
action | After full record creation (post-insert) |
{hook_prefix}deleted |
action | After record deletion |
{hook_prefix}map_db_raw_to_model_data |
filter | Transform raw DB row before setting on model |
{hook_prefix}selected_columns |
filter | Modify SELECT columns for read queries |
The {hook_prefix} is set automatically by DataLayerFactory as {prefix}_{table_name}_ (e.g., myplugin_orders_), or can be set manually via $store->set_hook_prefix().
BaseDataStore extends SqlQuery, which provides programmatic SQL construction:
$store->add_sql_clause( 'select', 'id, customer_id, total' );
$store->add_sql_clause( 'from', $store->get_table_name_with_prefix() );
$store->add_sql_clause( 'where', 'AND status = "active"' );
$store->add_sql_clause( 'order_by', 'created_at DESC' );
$store->add_sql_clause( 'limit', 'LIMIT 10' );
$sql = $store->get_query_statement();
// SELECT id, customer_id, total FROM wp_myplugin_orders WHERE 1=1 AND status = "active" ORDER BY created_at DESC LIMIT 10
$store->clear_all_clauses();Supported clause types: select, from, join, left_join, where, group_by, having, order_by, limit.
When a filter prefix is set, each clause passes through:
apply_filters( "{prefix}_sql_clauses_{type}_{context}", $clause, $sql_query_instance )
When an ObjectCache is set on a data store (done automatically by DataLayerFactory if the model defines $cache_group):
read()— checks cache first; on miss, reads from DB and caches the resultcreate()/update()— invalidates the cache for the specific IDdelete()/delete_by()/update_by()— flushes the entire cache group (bulk ops can't target individual IDs)
// Manual cache setup (if not using DataLayerFactory)
$engine = new WPCacheEngine( 'myplugin' );
$cache = new ObjectCache( $engine, 'myplugin_orders' );
$store->set_cache( $cache );BaseDataStore::query() provides WP_Query-style listing with pagination, filtering, search, and automatic caching. This is the low-level API that powers Model::query().
$store = DataLayerFactory::make_store( Task::class );
$result = $store->query( [
'status' => 'pending',
'per_page' => 10,
'page' => 1,
'orderby' => 'created_at',
'order' => 'DESC',
'search' => 'urgent',
] );
// Returns:
// [
// 'items' => object[], // Raw DB rows (stdClass)
// 'total' => 55, // Total matching rows
// 'per_page' => 10,
// 'current_page' => 1,
// 'total_pages' => 6,
// ]Note:
BaseDataStore::query()returns rawstdClassrows. UseModel::query()for hydrated model instances.
| Arg | Type | Default | Description |
|---|---|---|---|
per_page |
int | 20 |
Items per page (-1 for no limit) |
page |
int | 1 |
Current page number |
offset |
int | 0 |
Override offset (takes precedence over page) |
orderby |
string | 'id' |
Column to sort by (validated against field list) |
order |
string | 'DESC' |
ASC or DESC |
search |
string | '' |
Search term — LIKE %term% on searchable fields |
fields |
string | '*' |
Columns to select |
count_total |
bool | true |
Whether to run a COUNT query for pagination |
return |
string | 'results' |
'results' for rows, 'count' for count only, 'ids' for ID array |
no_cache |
bool | false |
Skip cache for this query |
date_query |
array | [] |
Date range (see below) |
Any key matching a table column is treated as an exact-match filter:
$store->query( [ 'status' => 'pending' ] );
// WHERE status = 'pending'Comparison operators are supported via suffixes:
| Suffix | SQL | Example |
|---|---|---|
{field}__in |
IN (...) |
'status__in' => ['pending', 'active'] |
{field}__not_in |
NOT IN (...) |
'status__not_in' => ['cancelled'] |
{field}__gt |
> |
'priority__gt' => 5 |
{field}__gte |
>= |
'priority__gte' => 5 |
{field}__lt |
< |
'priority__lt' => 10 |
{field}__lte |
<= |
'priority__lte' => 10 |
Unknown field names are silently ignored (no SQL injection risk).
Search works with LIKE %term% on fields declared by the data store's get_searchable_fields():
class TaskDataStore extends BaseDataStore {
protected function get_searchable_fields(): array {
return [ 'title', 'description' ];
}
}
// Searches title and description with OR logic
$store->query( [ 'search' => 'urgent' ] );
// WHERE (title LIKE '%urgent%' OR description LIKE '%urgent%')The base class returns an empty array, disabling search by default.
$store->query( [
'date_query' => [
'column' => 'due_date', // Must be a valid column name
'after' => '2025-01-01', // Optional: results after this date
'before' => '2025-12-31', // Optional: results before this date
],
] );// Default: returns rows
$result = $store->query();
// Count only (no items fetched)
$result = $store->query( [ 'return' => 'count' ] );
// $result['total'] = 55, $result['items'] = 55
// IDs only
$result = $store->query( [ 'return' => 'ids' ] );
// $result['items'] = [1, 5, 12, ...]Query results are automatically cached when the data store has an ObjectCache configured. The cache key is an MD5 hash of the serialized args array. Cache is invalidated when create(), update(), delete(), delete_by(), or update_by() are called (these flush the entire cache group).
// Skip cache for a specific query
$store->query( [ 'no_cache' => true ] );| Hook | Type | Parameters | Description |
|---|---|---|---|
{hook_prefix}query_args |
filter | $args |
Modify query args before execution |
{hook_prefix}query_results |
filter | $result, $args |
Modify results before return |
// Add a default filter to all task queries
add_filter( 'myplugin_tasks_query_args', function ( $args ) {
// Only show tasks assigned to the current user
$args['assigned_to'] = get_current_user_id();
return $args;
} );
// Modify results after query
add_filter( 'myplugin_tasks_query_results', function ( $result, $args ) {
// Add computed data to each row
foreach ( $result['items'] as &$item ) {
$item->is_overdue = strtotime( $item->due_date ) < time();
}
return $result;
}, 10, 2 );Static factory that wires models to data stores and auto-configures caching, hook prefixes, and SQL filter prefixes:
use WeDevs\WPKit\DataLayer\DataLayerFactory;
// Step 1: Initialize with your plugin prefix (call once during bootstrap)
DataLayerFactory::init( 'myplugin' );
// Step 2: Register model → data store mappings
DataLayerFactory::register_store( Order::class, OrderStore::class );
DataLayerFactory::register_store( Customer::class, CustomerStore::class );
// Step 3: Retrieve stores and create models
$store = DataLayerFactory::make_store( Order::class );
$order = DataLayerFactory::make_model( Order::class, 42 );When make_store() is called, the factory automatically:
- Sets the hook prefix to
{prefix}_{table_name}_viaset_hook_prefix() - Sets the SQL filter prefix to
{prefix}viaset_filter_prefix() - Creates an
ObjectCacheinstance if the model defines a non-empty$cache_group
Store instances are cached — calling make_store() twice returns the same instance.
When WooCommerce interop is needed, two bridge classes adapt WPKit objects for WC's data store system. These files have class_exists() guards at the top — the classes are only defined when WC is active.
Wraps a WPKit model as a WC_Data object:
use WeDevs\WPKit\DataLayer\Bridge\WCModelAdapter;
$order = new Order( 42 );
$wc_data = new WCModelAdapter( $order );
// WC_Data methods delegate to the WPKit model
$wc_data->save();
$wc_data->delete();
// Access the underlying WPKit model
$model = $wc_data->get_wpkit_model();Adapts a WPKit data store for WC's woocommerce_data_stores filter:
use WeDevs\WPKit\DataLayer\Bridge\WCDataStoreBridge;
add_filter( 'woocommerce_data_stores', function ( $stores ) {
$wpkit_store = DataLayerFactory::make_store( Order::class );
$stores['myplugin-order'] = new WCDataStoreBridge( $wpkit_store );
return $stores;
} );This allows WooCommerce to use your WPKit data store through its own WC_Data_Store::load() mechanism.
The migration system provides version-tracked database upgrades with support for background processing.
Static utilities for database table operations:
use WeDevs\WPKit\Migration\Schema;
// Get prefixed table name
$table = Schema::table( 'myplugin_orders' ); // 'wp_myplugin_orders'
// Create or update table (uses WordPress dbDelta)
Schema::create_table( "
CREATE TABLE {$table} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
customer_id bigint(20) unsigned NOT NULL DEFAULT 0,
total decimal(19,4) NOT NULL DEFAULT 0,
status varchar(50) NOT NULL DEFAULT 'pending',
created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
KEY customer_id (customer_id)
)
" );
// Check existence
Schema::table_exists( 'myplugin_orders' ); // bool
Schema::column_exists( 'myplugin_orders', 'total' ); // bool
// Drop table
Schema::drop_table( 'myplugin_orders' );Schema::create_table() automatically appends the WordPress charset/collation and includes wp-admin/includes/upgrade.php if not already loaded.
Each migration is a class whose name encodes the target version (V_1_0_0 = 1.0.0). All public static methods (except run and update_db_version) are auto-discovered and executed via PHP reflection.
Step 1: Create a plugin-specific base class that sets $db_version_key:
use WeDevs\WPKit\Migration\BaseMigration;
abstract class MyPluginMigration extends BaseMigration {
protected static string $db_version_key = 'myplugin_db_version';
}Step 2: Write version-specific migration classes:
class V_1_0_0 extends MyPluginMigration {
// Every public static method is auto-discovered and run
public static function create_orders_table(): void {
$table = Schema::table( 'myplugin_orders' );
Schema::create_table( "
CREATE TABLE {$table} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
customer_id bigint(20) unsigned NOT NULL DEFAULT 0,
total decimal(19,4) NOT NULL DEFAULT 0,
status varchar(50) NOT NULL DEFAULT 'pending',
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
)
" );
}
}
class V_1_1_0 extends MyPluginMigration {
public static function add_notes_column(): void {
global $wpdb;
$table = Schema::table( 'myplugin_orders' );
if ( ! Schema::column_exists( 'myplugin_orders', 'notes' ) ) {
$wpdb->query( "ALTER TABLE {$table} ADD COLUMN notes text DEFAULT NULL AFTER status" );
}
}
public static function seed_default_statuses(): void {
// Another method — auto-discovered and executed in the same migration
}
}Migrations can require a minimum DB version (useful for Pro/add-on plugins that depend on a base plugin's schema):
$registry->register( '2.0.0', [
'upgrader' => V_2_0_0::class,
'require' => '1.5.0', // Skipped if current DB version < 1.5.0
] );The Registry tracks version-to-class mappings and determines which migrations are pending. The Manager orchestrates execution. MigrationHooks wires everything into WordPress.
use WeDevs\WPKit\Migration\MigrationRegistry;
use WeDevs\WPKit\Migration\MigrationManager;
use WeDevs\WPKit\Migration\MigrationHooks;
// Registry: version → migration class mapping
$registry = new MigrationRegistry( 'myplugin_db_version', MYPLUGIN_VERSION );
$registry->register_many( [
'1.0.0' => V_1_0_0::class,
'1.1.0' => V_1_1_0::class,
'2.0.0' => V_2_0_0::class,
] );
// Status checks
$registry->is_upgrade_required(); // bool — any pending migrations?
$registry->get_db_installed_version(); // e.g., '1.0.0'
$registry->get_pending_migrations(); // versions > installed, sorted by version_compare
// Manager: runs pending migrations in version order
$manager = new MigrationManager( $registry, 'myplugin' );
$manager->do_upgrade(); // Runs all pending, fires {prefix}_upgrade_finished
// MigrationHooks: adds WordPress filter hooks
$hooks = new MigrationHooks( $manager, 'myplugin' );
$hooks->register();
// MigrationRESTController: REST API for status and upgrade
$rest = new MigrationRESTController( $manager, 'myplugin/v1' );
add_action( 'rest_api_init', [ $rest, 'register_routes' ] );| Hook | Type | Description |
|---|---|---|
{prefix}_upgrade_is_upgrade_required |
filter | Returns whether upgrade is needed |
{prefix}_upgrade_upgrades |
filter | Returns pending upgrade list |
{prefix}_upgrade_finished |
action | Fires after all migrations complete |
{prefix}_upgrade_is_not_required |
action | Fires when no upgrade is needed |
| Endpoint | Method | Description |
|---|---|---|
{prefix}/v1/migration/status |
GET | Returns migration status, log, and summary |
{prefix}/v1/migration/upgrade |
POST | Triggers admin upgrade (requires update_plugins capability) |
The migration system automatically logs each migration's execution. Use MigrationStatus to query the log:
$status = $manager->get_status();
// Full execution log (version-keyed array)
$log = $status->get_log();
// [
// '1.0.0' => [
// 'version' => '1.0.0',
// 'class' => 'App\\Migrations\\V_1_0_0',
// 'status' => 'completed', // pending | running | completed | failed
// 'started_at' => 1708900000,
// 'completed_at' => 1708900002,
// 'error' => null,
// ],
// ]
// Single migration entry
$entry = $status->get_status( '1.0.0' ); // array or null
// Is any migration currently running?
$status->is_running(); // bool
// Summary counts
$summary = $status->get_summary();
// [ 'total' => 5, 'completed' => 3, 'failed' => 0, 'running' => 1, 'pending' => 1 ]
// Clear the log (for debugging or re-runs)
$status->clear_log();Failed migrations are logged with their error message. The exception is re-thrown so existing error handling is preserved:
$entry = $status->get_status( '2.0.0' );
if ( $entry && $entry['status'] === 'failed' ) {
error_log( 'Migration 2.0.0 failed: ' . $entry['error'] );
}MigrationRESTController provides REST endpoints for polling migration status and triggering upgrades from the admin UI:
GET /wp-json/{prefix}/v1/migration/status
Requires the update_plugins capability. Returns:
{
"summary": { "total": 5, "completed": 3, "failed": 0, "running": 1, "pending": 1 },
"log": { "1.0.0": { "version": "1.0.0", "status": "completed", ... } },
"is_running": true,
"is_upgrade_required": false,
"db_version": "1.2.0",
"plugin_version": "1.2.0"
}For long-running tasks (data migration, batch updates), extend BackgroundProcess. It uses WP Cron for async execution with configurable time limits per batch — no WooCommerce dependency.
use WeDevs\WPKit\Migration\BackgroundProcess;
class OrderMigrationProcess extends BackgroundProcess {
protected string $prefix = 'myplugin'; // Required
protected string $action = 'migrate_orders';
protected function task( $item ) {
// Process a single queue item.
// Return false = done with this item (remove from queue).
// Return $item = re-queue for another attempt.
migrate_single_order( $item['order_id'] );
return false;
}
protected function complete(): void {
// Called after all items are processed. Override for cleanup.
update_option( 'myplugin_migration_complete', true );
}
}
// Usage
$process = new OrderMigrationProcess();
$process->init_hooks(); // Register WP Cron hooks — call during plugin bootstrap
$process->push_to_queue( [
[ 'order_id' => 1 ],
[ 'order_id' => 2 ],
[ 'order_id' => 3 ],
] );
$process->dispatch(); // Schedule first batch via WP Cron
// Status & control
$process->is_processing(); // bool — items in queue?
$process->cancel(); // Clear queue and unschedule cron
// Progress tracking (total is tracked automatically from push_to_queue calls)
$progress = $process->get_progress();
// [
// 'is_processing' => true,
// 'total' => 50, // Total items pushed across all push_to_queue() calls
// 'completed' => 30, // total - remaining
// 'remaining' => 20, // Items still in queue
// 'percentage' => 60, // (completed / total) * 100
// ]How it works internally:
- Queue is stored in WordPress options (
{prefix}_bg_{action}) - Each batch runs for up to
$time_limitseconds (default: 20) - After a batch, if items remain, a new cron event is scheduled 10 seconds later
- When the queue is empty,
complete()is called and the option is deleted
A common pattern is using a migration to queue a background process that seeds large amounts of data. This avoids HTTP timeouts during activation:
Step 1: Create the background process:
use WeDevs\WPKit\Migration\BackgroundProcess;
class TaskSeederProcess extends BackgroundProcess {
protected string $prefix = 'myplugin';
protected string $action = 'seed_tasks';
protected function task( $item ) {
$batch = $item['batch'];
$size = $item['size'];
$start = ( $batch - 1 ) * $size + 1;
global $wpdb;
$table = $wpdb->prefix . 'myplugin_tasks';
// Bulk INSERT for performance
$values = [];
for ( $i = 0; $i < $size; $i++ ) {
$n = $start + $i;
$values[] = $wpdb->prepare(
'(%s, %s, %s, %d, %s)',
"Task #{$n}",
"Auto-generated task {$n}.",
'pending',
rand( 1, 10 ),
current_time( 'mysql' )
);
}
$wpdb->query(
"INSERT INTO {$table} (title, description, status, priority, created_at) VALUES "
. implode( ',', $values )
);
return false; // Done with this batch
}
protected function complete(): void {
update_option( 'myplugin_seeder_completed', true );
}
}Step 2: Create the migration that queues batches:
class V_1_2_0 extends MyPluginMigration {
public static function seed_tasks(): void {
// Idempotency check
if ( get_option( 'myplugin_seeder_completed', false ) ) {
return;
}
$seeder = new TaskSeederProcess();
$seeder->init_hooks();
// 50 batches x 100 tasks = 5,000 tasks
$batches = [];
for ( $i = 1; $i <= 50; $i++ ) {
$batches[] = [ 'batch' => $i, 'size' => 100 ];
}
$seeder->push_to_queue( $batches );
$seeder->dispatch();
}
}Step 3: Register the cron hook in your plugin bootstrap (must run on every request so WP Cron can fire the handler):
( new TaskSeederProcess() )->init_hooks();Monitoring progress: Use the built-in get_progress() method:
$seeder = new TaskSeederProcess();
$progress = $seeder->get_progress();
if ( $progress['is_processing'] ) {
echo "Processing: {$progress['completed']}/{$progress['total']} batches ({$progress['percentage']}%)";
} elseif ( get_option( 'myplugin_seeder_completed' ) ) {
echo 'Seeding complete!';
}A system for collecting, filtering, and serving admin notices via REST API.
Implement NoticeProviderInterface to supply notices. Each provider returns an array of Notice objects or plain arrays:
use WeDevs\WPKit\AdminNotification\Contracts\NoticeProviderInterface;
use WeDevs\WPKit\AdminNotification\Notice;
use WeDevs\WPKit\AdminNotification\NotificationHelper;
class LicenseNoticeProvider implements NoticeProviderInterface {
public function get_notices(): array {
$notices = [];
if ( ! $this->is_license_valid() ) {
$notices[] = new Notice( [
'type' => 'warning',
'title' => 'License Expired',
'description' => 'Your license has expired. Renew to continue receiving updates.',
'priority' => 1,
'scope' => 'global',
'is_dismissible' => false,
'key' => 'license_expired',
'actions' => [
NotificationHelper::link_action( 'Renew License', 'https://example.com/renew', '_blank' ),
],
] );
}
return $notices;
}
}use WeDevs\WPKit\AdminNotification\Notice;
$notice = new Notice( [
'type' => 'info', // info | success | warning | error
'title' => 'Welcome!',
'description' => 'Thanks for installing our plugin.',
'priority' => 10, // Lower number = higher priority
'scope' => 'local', // 'local' or 'global'
'actions' => [], // Action button configs (see NotificationHelper)
'is_dismissible' => true,
'key' => 'welcome_notice', // Unique key for dismissal tracking
] );
$array = $notice->to_array(); // Serializable arrayCentral collector that gathers notices from providers and the WordPress filter, excludes dismissed notices, and sorts by priority:
use WeDevs\WPKit\AdminNotification\NoticeManager;
$manager = new NoticeManager( 'myplugin' );
// Register providers
$manager->register_provider( new LicenseNoticeProvider() );
$manager->register_provider( new UpdateNoticeProvider() );
// Get all notices (sorted by priority, dismissed excluded)
$all = $manager->get_notices();
// Filter by scope
$global = $manager->get_notices( 'global' );
$local = $manager->get_notices( 'local' );Static factory methods for creating notice and action button data structures:
use WeDevs\WPKit\AdminNotification\NotificationHelper;
// Notice creation shortcuts (returns arrays, not Notice objects)
$info = NotificationHelper::info( 'Title', 'Description' );
$warning = NotificationHelper::warning( 'Title', 'Description' );
$error = NotificationHelper::error( 'Title', 'Description' );
$success = NotificationHelper::success( 'Title', 'Description' );
// With extra properties
$notice = NotificationHelper::warning( 'Update Available', 'Version 2.0 is out.', [
'key' => 'update_available',
'is_dismissible' => true,
'actions' => [
NotificationHelper::link_action( 'Update Now', admin_url( 'update-core.php' ) ),
NotificationHelper::rest_action( 'Remind Later', '/wp-json/myplugin/v1/snooze' ),
],
] );Default values by type:
| Type | Priority | Scope |
|---|---|---|
| error | 1 | global |
| warning | 5 | local |
| info | 10 | local |
| success | 10 | local |
Exposes notices via the WordPress REST API and handles notice dismissal:
$controller = new NoticeRESTController( $manager, 'myplugin/v1' );
add_action( 'rest_api_init', function () use ( $controller ) {
$controller->register_routes();
} );Endpoints:
| Endpoint | Method | Description |
|---|---|---|
/wp-json/myplugin/v1/notices/admin |
GET | Get all notices (optional scope param: local or global) |
/wp-json/myplugin/v1/notices/dismiss |
POST | Dismiss a notice (required key param) |
Dismissed notices are stored in the {prefix}_dismissed_notices option as an array of keys.
Permission: All endpoints require manage_options capability.
A schema-driven REST controller for plugin settings pages, compatible with the @wedevs/plugin-ui <Settings> component. Provides GET/POST endpoints that load, validate, sanitize, and persist settings to wp_options using a nested key structure.
Extend BaseSettingsRESTController and implement the required methods:
use WeDevs\WPKit\Settings\BaseSettingsRESTController;
class MyPluginSettingsController extends BaseSettingsRESTController {
public function __construct() {
parent::__construct( 'myplugin/v1', 'settings', 'myplugin_settings' );
}
protected function get_settings_schema(): array {
return [
// Schema elements (see "Defining the Schema" below)
];
}
protected function get_permission_error_message( string $context ): string {
if ( 'write' === $context ) {
return __( 'You do not have permission to update settings.', 'myplugin' );
}
return __( 'You do not have permission to view settings.', 'myplugin' );
}
protected function get_validation_messages(): array {
return [
'number' => __( 'Must be a numeric value.', 'myplugin' ),
'switch' => __( 'Must be "on" or "off".', 'myplugin' ),
'invalid_option' => __( 'Invalid option selected.', 'myplugin' ),
'must_be_array' => __( 'Must be an array.', 'myplugin' ),
'invalid_options' => __( 'Contains invalid options.', 'myplugin' ),
'color_picker' => __( 'Must be a valid hex color (e.g. #ff0000).', 'myplugin' ),
'must_be_object' => __( 'Must be an object.', 'myplugin' ),
];
}
}The constructor takes three arguments:
| Parameter | Description | Example |
|---|---|---|
$namespace |
REST API namespace | 'myplugin/v1' |
$rest_base |
Route base path | 'settings' |
$option_prefix |
wp_options key prefix |
'myplugin_settings' |
Settings are stored as {option_prefix}_{page_id} in wp_options (e.g., myplugin_settings_general).
The schema is a flat array of element objects that the <Settings> component renders. Each element has a type (page, tab, section, field, etc.) and fields reference their parents via page_id, tab_id, section_id, etc.
protected function get_settings_schema(): array {
return [
[
'type' => 'page',
'id' => 'general',
'title' => 'General',
],
[
'type' => 'section',
'id' => 'store',
'page_id' => 'general',
'title' => 'Store Settings',
],
[
'type' => 'field',
'id' => 'store_name',
'variant' => 'text',
'page_id' => 'general',
'section_id' => 'store',
'title' => 'Store Name',
'default' => '',
],
[
'type' => 'field',
'id' => 'enable_tax',
'variant' => 'switch',
'page_id' => 'general',
'section_id' => 'store',
'title' => 'Enable Tax',
'default' => 'off',
],
[
'type' => 'field',
'id' => 'currency',
'variant' => 'select',
'page_id' => 'general',
'section_id' => 'store',
'title' => 'Currency',
'default' => 'USD',
'options' => [
[ 'label' => 'US Dollar', 'value' => 'USD' ],
[ 'label' => 'Euro', 'value' => 'EUR' ],
],
],
];
}Values are stored in a nested structure derived from the field's parent IDs. For the schema above, the wp_options entry myplugin_settings_general would contain:
[
'store' => [
'store_name' => 'My Store',
'enable_tax' => 'on',
'currency' => 'USD',
],
]add_action( 'rest_api_init', function () {
$controller = new MyPluginSettingsController();
$controller->register_routes();
} );Endpoints:
| Endpoint | Method | Description |
|---|---|---|
/wp-json/myplugin/v1/settings |
GET | Returns schema with current values populated in default props |
/wp-json/myplugin/v1/settings |
POST | Saves values; requires scopeId (page ID) and values (nested object) |
POST request body example:
{
"scopeId": "general",
"values": {
"store": {
"store_name": "My Store",
"enable_tax": "on",
"currency": "EUR"
}
}
}The controller provides built-in validation and sanitization for these variants:
| Variant | Value Type | Sanitization | Validation |
|---|---|---|---|
text |
string | sanitize_text_field() |
— |
number |
numeric | intval() |
is_numeric() |
switch |
"on" / "off" |
Whitelist check | Must be "on" or "off" |
select |
string | sanitize_text_field() |
Must be in options[].value |
radio_capsule |
string | sanitize_text_field() |
Must be in options[].value |
customize_radio |
string | sanitize_text_field() |
Must be in options[].value |
multicheck |
array | array_map( 'sanitize_text_field' ) |
All values must be in options[].value |
textarea |
string | sanitize_textarea_field() |
— |
color_picker |
string | sanitize_hex_color() |
Must match #rrggbb |
combine_input |
object | array_map( 'sanitize_text_field' ) |
Must be an array |
html |
— | Skipped (display-only) | — |
base_field_label |
— | Skipped (display-only) | — |
The base class provides English fallback strings. Subclasses must override get_permission_error_message() and get_validation_messages() with the plugin's text domain:
protected function get_permission_error_message( string $context ): string {
if ( 'write' === $context ) {
return __( 'You do not have permission to update settings.', 'myplugin' );
}
return __( 'You do not have permission to view settings.', 'myplugin' );
}
protected function get_validation_messages(): array {
return [
'number' => __( 'Must be a numeric value.', 'myplugin' ),
'switch' => __( 'Must be "on" or "off".', 'myplugin' ),
'invalid_option' => __( 'Invalid option selected.', 'myplugin' ),
'must_be_array' => __( 'Must be an array.', 'myplugin' ),
'invalid_options' => __( 'Contains invalid options.', 'myplugin' ),
'color_picker' => __( 'Must be a valid hex color (e.g. #ff0000).', 'myplugin' ),
'must_be_object' => __( 'Must be an object.', 'myplugin' ),
];
}Validation messages can also be filtered at runtime via the {prefix}_settings_validation_messages filter.
This section is for developers building add-ons or customizations for a plugin that uses WPKit. All examples assume a host plugin with prefix dokan — replace with the actual plugin's prefix.
Every model property passes through a WordPress filter when accessed with 'view' context (the default). This lets you modify displayed values without changing stored data:
// Filter format: {hook_prefix}{object_type}_get_{property_name}
// Example: dokan_ + vendor_balance + _get_ + debit
// Modify a vendor balance's debit amount when displayed
add_filter( 'dokan_vendor_balance_get_debit', function ( $value, $model ) {
// Apply a platform fee adjustment for display
return $value * 0.95;
}, 10, 2 );
// Modify order status for display
add_filter( 'myplugin_order_get_status', function ( $value, $model ) {
if ( $value === 'pending' && $model->get_total( 'edit' ) > 1000 ) {
return 'pending_review';
}
return $value;
}, 10, 2 );Tip: Use
$model->get_prop( 'field', 'edit' )to get the raw value without filters. This is useful inside filter callbacks to avoid infinite loops.
// Runs before create OR update
add_action( 'dokan_before_vendor_balance_save', function ( $model, $data_store ) {
// Validate, log, or modify the model before it hits the database
if ( $model->get_debit( 'edit' ) < 0 ) {
throw new \Exception( 'Debit cannot be negative.' );
}
}, 10, 2 );
// Runs after create OR update
add_action( 'dokan_after_vendor_balance_save', function ( $model, $data_store ) {
// Trigger notifications, sync to external systems, etc.
do_action( 'my_addon_balance_changed', $model->get_id() );
}, 10, 2 );// Return a non-null value to prevent deletion
add_filter( 'dokan_pre_delete_vendor_balance', function ( $check, $model, $force_delete ) {
if ( $model->get_status( 'edit' ) === 'locked' ) {
return false; // Prevent deletion, delete() returns false
}
return $check; // Return null to allow deletion
}, 10, 3 );// Modify data just before INSERT
add_filter( 'dokan_vendor_balance_insert_data', function ( $data ) {
$data['source'] = 'my_addon';
return $data;
} );
// After a record is created
add_action( 'dokan_vendor_balance_created', function ( $id, $data ) {
// $id = new record ID, $data = inserted data array
error_log( "New balance record #{$id} created" );
}, 10, 2 );
// After a record is deleted
add_action( 'dokan_vendor_balance_deleted', function ( $id, $result ) {
// Clean up related data in your add-on's tables
}, 10, 2 );
// Transform data after reading from the database
add_filter( 'dokan_vendor_balance_map_db_raw_to_model_data', function ( $data, $raw_data ) {
// Add computed fields, transform values, etc.
$data['display_amount'] = number_format( $data['debit'] - $data['credit'], 2 );
return $data;
}, 10, 2 );If the host plugin sets a SQL filter prefix, you can modify query clauses before they execute:
// Filter format: {prefix}_sql_clauses_{clause_type}_{context}
// clause_type: select, from, join, left_join, where, group_by, having, order_by, limit
// Add a WHERE condition to all vendor_balance queries
add_filter( 'dokan_sql_clauses_where_vendor_balance', function ( $where_clause, $sql_query ) {
// Only show records from the last 30 days
$where_clause .= " AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)";
return $where_clause;
}, 10, 2 );
// Add a JOIN for custom reporting
add_filter( 'dokan_sql_clauses_join_vendor_balance', function ( $join_clause, $sql_query ) {
global $wpdb;
$join_clause .= " INNER JOIN {$wpdb->prefix}my_addon_meta ON vendor_balance.id = my_addon_meta.balance_id";
return $join_clause;
}, 10, 2 );
// Modify selected columns
add_filter( 'dokan_vendor_balance_selected_columns', function ( $columns ) {
$columns[] = 'my_custom_field';
return $columns;
} );The simplest way — no class needed:
// Filter name: {prefix}_admin_notices
add_filter( 'dokan_admin_notices', function ( $notices ) {
// Check your add-on's conditions
if ( ! get_option( 'my_addon_configured' ) ) {
$notices[] = [
'type' => 'warning',
'title' => 'My Add-on Setup Required',
'description' => 'Please complete the initial configuration to enable all features.',
'priority' => 5,
'scope' => 'global',
'is_dismissible' => true,
'key' => 'my_addon_setup_required',
'actions' => [
[
'type' => 'primary',
'text' => 'Configure Now',
'action' => admin_url( 'admin.php?page=my-addon-settings' ),
'target' => '_self',
],
],
];
}
return $notices;
} );For more complex logic, implement the NoticeProviderInterface and register it with the host plugin's NoticeManager (if they expose it):
use WeDevs\WPKit\AdminNotification\Contracts\NoticeProviderInterface;
use WeDevs\WPKit\AdminNotification\Notice;
class MyAddonNoticeProvider implements NoticeProviderInterface {
public function get_notices(): array {
$notices = [];
if ( $this->needs_migration() ) {
$notices[] = new Notice( [
'type' => 'info',
'title' => 'Data Migration Available',
'description' => 'My Add-on can migrate your existing data for better performance.',
'priority' => 8,
'scope' => 'local',
'key' => 'my_addon_migration',
] );
}
return $notices;
}
}
// Register with the host plugin's NoticeManager
// (the host plugin needs to expose its manager instance or provide a hook)
add_action( 'dokan_notice_manager_init', function ( $manager ) {
$manager->register_provider( new MyAddonNoticeProvider() );
} );If your add-on has its own database tables, use WPKit's migration system with your own prefix and version key:
use WeDevs\WPKit\Migration\BaseMigration;
use WeDevs\WPKit\Migration\MigrationRegistry;
use WeDevs\WPKit\Migration\MigrationManager;
use WeDevs\WPKit\Migration\MigrationHooks;
use WeDevs\WPKit\Migration\Schema;
// 1. Create your own migration base class
abstract class MyAddonMigration extends BaseMigration {
protected static string $db_version_key = 'my_addon_db_version';
}
// 2. Write migration classes
class V_1_0_0 extends MyAddonMigration {
public static function create_addon_table(): void {
$table = Schema::table( 'my_addon_data' );
Schema::create_table( "
CREATE TABLE {$table} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
balance_id bigint(20) unsigned NOT NULL,
extra_data text,
PRIMARY KEY (id),
KEY balance_id (balance_id)
)
" );
}
}
// 3. Wire it up (independent from the host plugin's migration system)
$registry = new MigrationRegistry( 'my_addon_db_version', MY_ADDON_VERSION );
$registry->register( '1.0.0', V_1_0_0::class );
$manager = new MigrationManager( $registry, 'my_addon' );
( new MigrationHooks( $manager, 'my_addon' ) )->register();You can also hook into the host plugin's migration lifecycle:
// Run your migration after the host plugin finishes its upgrade
add_action( 'dokan_upgrade_finished', function () {
// Check if your add-on needs to run migrations too
$my_manager = get_my_addon_migration_manager();
if ( $my_manager->is_upgrade_required() ) {
$my_manager->do_upgrade();
}
} );If the host plugin uses WPKit caching, you may need to invalidate cache when your add-on modifies data outside of the normal DataStore methods:
// If you have access to the data store instance
$store = DataLayerFactory::make_store( VendorBalance::class );
$cache = $store->get_cache();
if ( $cache ) {
// Invalidate a single cached object
$cache->remove( $balance_id );
// Or flush the entire group (e.g., after bulk operations)
$cache->flush();
}If you perform direct database writes that bypass the DataStore (e.g., raw $wpdb queries), always flush the relevant cache group:
global $wpdb;
// Direct update bypassing the DataStore
$wpdb->update(
$wpdb->prefix . 'dokan_vendor_balance',
[ 'status' => 'approved' ],
[ 'vendor_id' => $vendor_id ]
);
// IMPORTANT: Flush the cache since you bypassed the DataStore
$store = DataLayerFactory::make_store( VendorBalance::class );
if ( $store && $store->get_cache() ) {
$store->get_cache()->flush();
}If the host plugin uses BaseSettingsRESTController, you can extend its settings via filters. All hooks are prefixed with the controller's option_prefix (e.g., dokan_settings).
// Add a custom field to the settings schema
add_filter( 'dokan_settings_settings_schema', function ( $schema, $request ) {
$schema[] = [
'type' => 'field',
'id' => 'my_addon_feature',
'variant' => 'switch',
'page_id' => 'general',
'section_id' => 'store',
'title' => 'My Add-on Feature',
'default' => 'off',
];
return $schema;
}, 10, 2 );// Enforce a business rule before saving
add_filter( 'dokan_settings_settings_before_save', function ( $sanitized, $values, $scope_id ) {
if ( $scope_id === 'general' && isset( $sanitized['store']['commission'] ) ) {
$sanitized['store']['commission'] = min( $sanitized['store']['commission'], 50 );
}
return $sanitized;
}, 10, 3 );// Clear a cache or sync when settings change
add_action( 'dokan_settings_settings_after_save', function ( $merged, $sanitized, $scope_id ) {
if ( $scope_id === 'payment' ) {
delete_transient( 'my_addon_payment_config' );
}
}, 10, 3 );// Add validation for a specific field
add_filter( 'dokan_settings_settings_validate_field', function ( $error, $field, $value ) {
if ( $field['id'] === 'api_key' && strlen( $value ) < 20 ) {
return 'API key must be at least 20 characters.';
}
return $error;
}, 10, 3 );// Override sanitization for a specific field
add_filter( 'dokan_settings_settings_sanitize_field', function ( $clean, $field, $value ) {
if ( $field['id'] === 'custom_css' ) {
return wp_strip_all_tags( $value );
}
return $clean;
}, 10, 3 );// Allow editors to view settings but not save
add_filter( 'dokan_settings_settings_capability', function ( $capability, $context, $request ) {
if ( $context === 'read' ) {
return 'edit_pages';
}
return $capability;
}, 10, 3 );All hooks use the consumer-provided prefix. WPKit does not register any hooks with a library-level prefix.
The model's {hook_prefix} is set on the model class (e.g., $hook_prefix = 'dokan_').
| Hook | Type | Parameters | Description |
|---|---|---|---|
{hook_prefix}before_{object_type}_save |
action | $model, $data_store |
Before create or update |
{hook_prefix}after_{object_type}_save |
action | $model, $data_store |
After create or update |
{hook_prefix}pre_delete_{object_type} |
filter | null, $model, $force_delete |
Before delete (non-null return cancels) |
{hook_prefix}{object_type}_get_{prop} |
filter | $value, $model |
Filter property value on 'view' context |
The store's {hook_prefix} is auto-set by DataLayerFactory as {prefix}_{table_name}_ (e.g., dokan_vendor_balance_).
| Hook | Type | Parameters | Description |
|---|---|---|---|
{hook_prefix}insert_data |
filter | $data |
Modify data before INSERT |
{hook_prefix}insert_data_format |
filter | $format, $data |
Modify format array before INSERT |
{hook_prefix}after_insert |
action | $result, $data |
After wpdb::insert() completes |
{hook_prefix}created |
action | $inserted_id, $data |
After record creation |
{hook_prefix}deleted |
action | $id, $result |
After record deletion |
{hook_prefix}map_db_raw_to_model_data |
filter | $data, $raw_data |
Transform raw DB row |
{hook_prefix}selected_columns |
filter | $columns |
Modify SELECT columns |
{hook_prefix}query_args |
filter | $args |
Modify query() args before execution |
{hook_prefix}query_results |
filter | $result, $args |
Modify query() results before return |
The {prefix} is the plugin-level prefix set via set_filter_prefix() (e.g., dokan).
| Hook | Type | Parameters | Description |
|---|---|---|---|
{prefix}_sql_clauses_{type}_{context} |
filter | $clause, $sql_query |
Modify SQL clause before execution |
{type} is one of: select, from, join, left_join, where, group_by, having, order_by, limit.
| Hook | Type | Parameters | Description |
|---|---|---|---|
{prefix}_upgrade_is_upgrade_required |
filter | $required |
Check if upgrade is needed |
{prefix}_upgrade_upgrades |
filter | $upgrades |
Get pending upgrades list |
{prefix}_upgrade_finished |
action | — | All migrations completed |
{prefix}_upgrade_is_not_required |
action | — | No upgrade needed |
| Route | Method | Permission | Description |
|---|---|---|---|
{prefix}/v1/migration/status |
GET | update_plugins |
Returns migration log, summary, and status |
{prefix}/v1/migration/upgrade |
POST | update_plugins |
Triggers admin upgrade |
The {prefix} is the option_prefix passed to the controller's constructor (e.g., dokan_settings).
| Hook | Type | Parameters | Description |
|---|---|---|---|
{prefix}_settings_capability |
filter | $capability, $context, $request |
Customize required capability ($context is 'read' or 'write') |
{prefix}_settings_schema |
filter | $schema, $request |
Modify schema before values are loaded |
{prefix}_settings_get_response |
filter | $response_data, $request |
Modify full GET response (schema + values) |
{prefix}_settings_loaded_values |
filter | $values, $schema |
Modify loaded values after defaults applied |
{prefix}_settings_validate_field |
filter | $error, $field, $value |
Override validation for a single field |
{prefix}_settings_validation_errors |
filter | $errors, $values, $scope_id |
Add/remove validation errors before rejecting |
{prefix}_settings_validation_messages |
filter | $messages |
Translate/customize error message strings |
{prefix}_settings_sanitize_field |
filter | $clean, $field, $value |
Override sanitization for a single field |
{prefix}_settings_before_save |
filter | $sanitized, $values, $scope_id |
Modify sanitized values before DB write |
{prefix}_settings_after_save |
action | $merged, $sanitized, $scope_id |
React after settings are saved |
| Route | Method | Permission | Description |
|---|---|---|---|
{namespace}/{rest_base} |
GET | manage_options |
Returns schema with current values |
{namespace}/{rest_base} |
POST | manage_options |
Saves values for a scopeId (page ID) |
| Option Key | Description |
|---|---|
{option_prefix}_{page_id} |
Nested settings values for a page (e.g., myplugin_settings_general) |
| Hook | Type | Parameters | Description |
|---|---|---|---|
{prefix}_admin_notices |
filter | $notices |
Collect/modify admin notices |
| Route | Method | Permission | Description |
|---|---|---|---|
{namespace}/notices/admin |
GET | manage_options |
Get admin notices (optional scope param) |
{namespace}/notices/dismiss |
POST | manage_options |
Dismiss a notice (required key param) |
| Option Key | Component | Description |
|---|---|---|
{db_version_key} |
Migration | Stores installed DB version (e.g., '1.2.0') |
{prefix}_is_upgrading_db |
MigrationManager | Tracks ongoing upgrade (stores pending list) |
{prefix}_migration_log |
MigrationManager | Per-migration execution log (version, status, timestamps, errors) |
{prefix}_bg_{action} |
BackgroundProcess | Queue storage (array of items) |
{prefix}_bg_{action}_total |
BackgroundProcess | Total items pushed (for progress percentage) |
{prefix}_dismissed_notices |
NoticeRESTController | Array of dismissed notice keys |