A toolkit for building WordPress plugins: a routing layer over REST, AJAX and static pages; form requests with validation and authorization; a fluent response builder; a safe HTTP client; and thin facades over hooks, shortcodes, migrations and the activation lifecycle. Public API is kept backward compatible.
Add the repository to your composer.json:
"repositories": [
{ "type": "vcs", "url": "https://github.com/Bit-Apps-Pro/wp-kit" }
]Then require the package:
composer require bitapps/wp-kit:dev-mainuse BitApps\WPKit\Http\Router\Router;
$api = new Router('api', 'myplugin', 'v1'); // REST namespace: myplugin/v1
$api->setMiddlewares(['auth' => AuthMiddleware::class]);
$api->registerFile(__DIR__ . '/routes/api.php'); // define routes there
add_action('rest_api_init', [$api, 'register']);Use 'ajax' for admin-ajax routes and register those on init. Construct the
router before declaring its routes — a new router becomes the current one.
// routes/api.php
use BitApps\WPKit\Http\Router\Route;
Route::get('entries', [EntryController::class, 'index']);
Route::post('entries/{id}', [EntryController::class, 'update'])->middleware('auth');
Route::prefix('admin')->group(function () {
Route::get('stats', [StatsController::class, 'show'])->middleware('auth');
});Path params ({id}, optional {slug?}) are injected by name into the action.
use BitApps\WPKit\Http\Response;
class EntryController
{
public function index()
{
return Response::success(['items' => []]); // 200
}
public function update($id)
{
return Response::error(['id' => $id], 404) // custom status
->code('NOT_FOUND')
->message('Entry not found');
}
}Returning a plain array/string wraps it in a success envelope automatically.
Type-hint a Request subclass on the action; wp-kit builds it, authorizes,
and validates before the action runs. Failures short-circuit with an error
response and the action never executes.
use BitApps\WPKit\Http\Request\Request;
class EntryRequest extends Request
{
public function authorize()
{
return current_user_can('edit_posts');
}
public function rules()
{
return ['title' => ['required'], 'body' => ['required']];
}
}
// EntryController::store(EntryRequest $request)
$data = $request->all();
$title = $request->input('title');
$ip = $request->ip();Register aliases on the router, then apply them per route or group. A middleware
class defines handle(Request $request, ...$params) and returns true to pass
or a Response to block. Middleware fails closed — an unregistered alias, a
missing class, or one without handle() is rejected.
$router->setMiddlewares(['auth' => AuthMiddleware::class, 'role' => RoleMiddleware::class]);
Route::post('entries', [EntryController::class, 'store'])->middleware('auth', 'role:editor');use BitApps\WPKit\Http\Client\Http;
use BitApps\WPKit\Http\Client\HttpClient;
// Facade: returns the decoded JSON body (array) or raw string, or a WP_Error
$data = Http::post('https://api.example.com/hooks', ['event' => 'created']);
// Instance: when you also need the status code / headers
$client = new HttpClient();
$body = $client->request('https://api.example.com/hooks', 'POST', ['event' => 'created']);
$code = $client->getResponseCode();Dynamic instance verbs include get, post, put, patch, delete, head,
and options. Query parameters are appended safely when the request path already
contains a query string.
Multipart requests use standards-compliant boundaries and CRLF framing. Multipart mode cannot be combined with JSON, form parameters, or a separate request body; field names, filenames, boundaries, and part headers reject control characters.
Safe by default (wp_safe_remote_request). To reach known internal hosts, an
administrator must explicitly enable unsafe URLs and allowlist each exact trusted
endpoint host:
$client = (new HttpClient())
->allowUnsafeUrls(true, ['10.0.0.20', 'internal-api.example']);Authorization is host-only: URLs must use HTTP or HTTPS, but any port and path on an allowlisted host remain reachable. Validate untrusted URL components separately.
Only administrator-configured trusted endpoints belong in this allowlist; never derive hosts from arbitrary request values. Unsafe requests never follow redirects.
use BitApps\WPKit\Hooks\Hooks;
use BitApps\WPKit\Shortcode\Shortcode;
Hooks::addAction('init', [$plugin, 'boot']);
Shortcode::addShortcode('myplugin_widget', [$plugin, 'renderWidget']);Installer handles activation/deactivation/uninstall and requirement checks;
Migration + MigrationHelper run schema migrations; StaticRouter maps custom
front-end page URLs (via rewrite rules) to routes. Static routes enforce their
declared HTTP methods. Their actions must return string-compatible page content;
null renders no additional content.
Http\Router—Router,Route/RouteBase, REST/AJAX transports,StaticRouter,RequestTypeHttp\Request\Request— form requests, input access, IP resolutionHttp\Response— fluent response builderHttp\Client—HttpClient,HttpfacadeHttp\Detection—ClientIpResolver,UserAgentHooks,Shortcode— WordPress facadesInstaller,Migration— plugin lifecycleHelpers—Arr,JSON,Slug,DateTimeHelper;Utils\Capabilities
-
Route middleware fails closed. Every alias passed to
middleware()must be registered withRouter::setMiddlewares(), and its class must definehandle(). -
WPKit does not infer route authorization. Protect non-public REST/static routes with middleware or a request
authorize()method, and add capability plus nonce checks to state-changing AJAX routes. -
Request::ip()usesREMOTE_ADDRunless that address is a configured trusted proxy. Configure exact proxy addresses or CIDR ranges before acceptingX-Forwarded-For:Request::setTrustedProxies(['10.0.0.0/8', '2001:db8::/32']);
-
HttpClientuseswp_safe_remote_request()by default. Internal or otherwise unsafe URLs require an explicit, administrator-configured exact-host allowlist:$client->allowUnsafeUrls(true, ['10.0.0.20', 'internal-api.example']);
Do not allowlist hosts supplied by arbitrary requests. Unsafe requests do not follow redirects.
HttpClient::allowUnsafeUrls()remains a valid call, but calling it without an explicit host allowlist now intentionally fails closed. Unsafe requests return aWP_Errorwith theunsafe_url_not_allowedcode instead of reaching the transport. Configure exact, trusted hosts withallowUnsafeUrls(true, ['internal-api.example']).Response::headers()now requires an array and validates every entry throughResponse::header(); invalid header names, values containing CR/LF/NUL, and non-scalar values throwInvalidArgumentExceptioninstead of being stored silently.Router::getRegisteredMiddleware()throwsMiddlewareConfigurationExceptionfor unregistered aliases, missing classes, or classes withouthandle()— it previously returnednull. Route dispatch catches this internally and responds withMIDDLEWARE_CONFIGURATION; only direct callers need to migrate.Router::instance($type)now returns the router of the requested type (or creates one) instead of silently returning whatever router was constructed last. No-argument calls keep returning the current router. Creating a router (directly or viainstance($type)miss) still makes it the current router, so declare routes before constructing transports.Response::getCode()returnsnull(previously'') when neither a code nor a status has been set.RouteRegister::handleMiddleware()called directly now returnstrue/false(previouslynull) and records the denial response without emitting it; full dispatch throughhandleRequest()is unchanged.- Direct calls to
RouteRegister::getRequest()/getParamValue()on a denying or invalid request record the failure response and return the built request /nullrespectively — they never throw. - Route paths are compiled by one grammar for REST, AJAX, and static routes:
literal segments are regex-quoted,
{param?}now also matches with no trailing separator (entries/{slug?}matchesentries), and duplicate or invalid parameter names throwInvalidArgumentExceptionat registration. Trade-off: an optional param no longer matches the empty-value-with-trailing- slash form (entries/) on AJAX routes — useentries(no slash) instead. - Static routes now generate one complete, anchored rewrite rule for every declared path, including literal and optional-parameter paths. Undeclared intermediate prefixes are no longer registered as routes.
- Static page dispatch now enforces the route's declared HTTP methods and accepts
only string-compatible output. Direct
RouteRegister::handleRequest()and custom router types continue returning raw values for backward compatibility. Response::success()andResponse::error()now start with fresh metadata; messages, codes, and headers no longer leak from an earlier factory response. Bulk header changes are validated completely before replacing existing headers.Request::input()and the other frozen request/IP extension points intentionally remain untyped so downstream subclasses with legacy signatures remain compatible.- IP/device detection classes moved to
Http\Detection\(ClientIpResolver,UserAgent). TheHttp\IpTooltrait andRequest::ip()/device()facade are unchanged. ModernEdg/user agents are recognized as Edge, and OS matching no longer suppresses malformed regular-expression warnings.
Lightweight IoC container for managing service bindings and providers. Supports constructor autowiring: when resolving a type, dependencies are automatically injected if their classes are type-hinted in the constructor.
use BitApps\WPKit\Container\Application;
use BitApps\WPKit\Container\ServiceProvider;
// Build a container with service providers.
$app = new Application();
// Register a simple binding.
$app->bind(Logger::class, FileLogger::class);
// Register a shared singleton instance (same instance every time).
$app->singleton(Database::class, function ($container) {
return new Database($container->make(Connection::class));
});
// Resolve and autowire dependencies.
$logger = $app->make(Logger::class); // resolves as FileLogger
$db = $app->make(Database::class); // autowires Connection
// Register a service provider.
class MailProvider extends ServiceProvider {
public function register(): void {
$this->app->singleton(Mailer::class);
}
public function boot(): void {
// Bootstrap logic after all providers are registered.
}
}
$app->register(MailProvider::class);
// Boot all registered providers.
$app->boot();Typed get/set/save access to wp_options rows. Define a schema of typed fields,
then use a SettingsRepository to load, modify, and persist them with automatic
casting and sanitization.
use BitApps\WPKit\Settings\SettingField;
use BitApps\WPKit\Settings\SettingsSchema;
use BitApps\WPKit\Settings\SettingsRepository;
// Define a schema with typed fields.
$schema = (new SettingsSchema())
->add(
SettingField::bool('enabled', false, 'general'),
SettingField::int('max_retries', 3, 'general'),
SettingField::enum('log_level', ['debug', 'info', 'error'], 'info', 'logging'),
SettingField::string('api_key', '', 'api')
);
// Create a repository for the wp_options row.
$repo = new SettingsRepository('myplugin_settings', $schema);
// Get typed values (with automatic casting from stored values).
$enabled = $repo->get('enabled'); // bool
$level = $repo->get('log_level'); // string (validated against choices)
// Set values (cast to field type).
$repo->set('enabled', '1')->set('max_retries', '5');
// Bulk update and save.
$repo->fill(['enabled' => true, 'api_key' => 'secret']);
$repo->save();Registers custom cron schedules and recurring/one-off jobs. Wire callbacks onto WordPress cron hooks with fluent scheduling.
use BitApps\WPKit\Cron\Scheduler;
$scheduler = new Scheduler();
// Define a custom cron interval.
$scheduler->addSchedule('every_minute', 60, 'Every Minute');
// Register a recurring job.
$scheduler
->job('myplugin_hourly_sync', 'hourly', function () {
// Sync data every hour.
})
->job('myplugin_sync', 'every_minute', function () {
// Custom schedule set via addSchedule.
});
// Register a one-off job.
$scheduler->once('myplugin_one_time', time() + 3600, function () {
// Fire once, in 1 hour.
});
// Wire hooks and schedule pending events.
$scheduler->boot();
// On plugin deactivation, clear all scheduled events.
$scheduler->clearAll();Manages named cache stores (array, transient, WP object cache, or file). Access
stores via the manager, or use the static Cache facade.
use BitApps\WPKit\Cache\CacheManager;
use BitApps\WPKit\Cache\Cache;
// Configure a manager with multiple stores.
$manager = new CacheManager([
'default' => 'transient',
'prefix' => 'myplugin_',
'stores' => [
'file' => ['path' => '/var/cache/myplugin'],
'object' => ['group' => 'myplugin_group'],
],
]);
// Access a named store (defaults to 'transient').
$cache = $manager->store('file');
// Cache operations.
$cache->put('user_123', $userData, 3600);
$user = $cache->get('user_123');
// Callback-based caching (compute and store on miss).
$data = $cache->remember('expensive_key', 7200, function () {
return compute_expensive_data();
});
$cache->forget('user_123');
$cache->flush();
// Use the static facade (requires setManager first).
Cache::setManager($manager);
$data = Cache::remember('cached_posts', 3600, function () {
return get_posts(['numberposts' => 10]);
});
// Available stores:
// - 'array': in-memory only, lost on shutdown.
// - 'transient': WordPress transients (data persists across requests).
// - 'object': WordPress object cache (non-persistent unless a drop-in is installed).
// - 'file': filesystem, requires configured path.
// Note: TransientStore::flush() is a documented no-op (WordPress limitation).composer test
composer coveragePHPUnit tests are grouped by public feature under tests/ to protect
compatibility while internals are refactored. The PHPDBG coverage command
enforces 100% executable-line coverage for selected HTTP hardening methods.