merkushin / wpal
Provides an abstraction layer for WordPress API
Requires
- php: >=7.4
Requires (Dev)
- dealerdirect/phpcodesniffer-composer-installer: ^1.0
- nikic/php-parser: ^5.6
- php-stubs/wordpress-stubs: ^7.1
- phpcompatibility/php-compatibility: ^10.0@alpha
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^9.6
- squizlabs/php_codesniffer: ^3.13
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-10-09 09:39:46 UTC
README
WPAL gives WordPress an object-oriented API that is pleasant to use and easy to unit-test without WordPress.
It has two layers:
- Api (PHP 8.4+): a designed API with real types, value objects, exceptions instead of
WP_Error, and in-memory fakes for tests. Start here. - Service (PHP 7.4+): every WordPress function as a method, generated from WordPress itself and kept in step with each release. Use it for anything the Api doesn't cover yet, or on older PHP.
Api
use Merkushin\Wpal\Api\Posts\SortBy; use Merkushin\Wpal\Wpal; $wp = new Wpal(); $wp->hooks()->onAction( 'wp_enqueue_scripts', function () use ( $wp ): void { $wp->assets()->script( 'my-plugin' ) ->src( plugins_url( 'build/app.js', __FILE__ ) ) ->deps( 'wp-element' ) ->data( 'myPlugin', [ 'limit' => $wp->options()->int( 'my_plugin_limit', 10 ) ] ) ->defer() ->enqueue(); } ); $recent = $wp->posts()->query()->type( 'page' )->sortBy( SortBy::Modified )->limit( 5 )->get(); $post = $wp->posts()->create( title: 'Hello', status: 'publish' ); // throws WordPressError on failure
What changes compared to WordPress:
- Hook callbacks receive as many arguments as they declare: no
$accepted_args.onAction()returns a subscription you canremove(). options()->get()returns your default for a missing option, notfalse;int(),bool(),string()andarray()convert WordPress's stored strings.- Posts come back as
Postvalue objects.find()returnsnull,get()throwsPostNotFound, and failed writes throwWordPressError. - Scripts are built fluently;
data()passes JSON-encoded data safely instead ofwp_localize_script()'s strings.
Abilities and AI
Describe what your plugin can do as an ability, and AI agents, the REST API and other plugins can discover and run
it. WPAL registers it on the right hook, whenever you call register():
$wp->abilities()->define( 'my-plugin/summarize-post' ) ->label( 'Summarize post' ) ->description( 'Returns a one-paragraph summary of a post.' ) ->category( 'content' ) ->input( [ 'type' => 'object', 'properties' => [ 'id' => [ 'type' => 'integer' ] ], 'required' => [ 'id' ] ] ) ->readonly() ->public() ->requireCapability( 'read' ) ->execute( fn ( array $input ): string => $wp->ai() ->prompt( $wp->posts()->get( $input['id'] )->content ) ->system( 'Summarize in one paragraph.' ) ->generateText() ) ->register(); $summary = $wp->abilities()->execute( 'my-plugin/summarize-post', [ 'id' => 42 ] );
ai() uses the site's configured provider through WordPress's AI Client; generateText() and generateJson() throw
WordPressError instead of returning WP_Error.
Testing
Pass in-memory fakes for the services your code uses; they behave like WordPress without it:
use Merkushin\Wpal\Api\Testing\FakeAi; use Merkushin\Wpal\Api\Testing\FakeHooks; use Merkushin\Wpal\Api\Testing\FakeOptions; use Merkushin\Wpal\Wpal; $wp = new Wpal( hooks: new FakeHooks(), options: new FakeOptions( [ 'my_plugin_limit' => '3' ] ), ai: new FakeAi( [ 'A short summary.' ] ), // scripted responses; prompts are recorded ); ( new MyPlugin( $wp ) )->boot(); $wp->hooks()->doAction( 'init' );
The full reference is in docs/api.md. Coding agents: start with llms.txt.
Service
Every WordPress function, grouped into services such as Posts, Hooks or Assets:
use Merkushin\Wpal\ServiceFactory; $assets = ServiceFactory::create_assets(); $assets->wp_enqueue_script( 'my-plugin', plugins_url( 'build/app.js', __FILE__ ), [], '1.0.0', [ 'in_footer' => true ] );
In tests, swap a service for a mock with ServiceFactory::set_custom_assets( $mock ); the Api's default services pick
it up too. docs/services.md lists which service wraps which function.
Shipping only what you use
WPAL is about 2 MB, nearly all of it services a plugin doesn't call. In your release build, after
composer install --no-dev, remove the rest:
vendor/bin/wpal-prune --scan=src
--scan finds the services your code uses: WPAL classes it refers to, ServiceFactory::create_*() calls, and Wpal
accessors such as ->posts(). It fails if the code refers to something WPAL doesn't have. You can also name services
instead: Hooks keeps a Service, api:Hooks an Api service along with the Services it's built on. A plugin that only
uses the Service layer ships no PHP 8.4 Api code. Run it before scoping tools such as PHP-Scoper or wp-scoper;
--dry-run --format=json prints the plan instead.
Calling a ServiceFactory or Wpal method for a removed service fails with "class not found", so test the built
plugin, not only the sources.
Requirements
- PHP 7.4 or later; the Api layer needs PHP 8.4. On older PHP,
new Wpal()throws a clear error and the Service layer works as before. - The latest WordPress version. Each WPAL release targets the WordPress version that was current when it shipped; on an older WordPress, use an older WPAL release.
Compatibility
- Api follows semantic versioning strictly.
- Service mirrors WordPress functions, so it follows WordPress:
- Code that calls a service keeps working across releases unless WordPress itself breaks the same call.
- Implementing service interfaces yourself is not supported: they gain methods and parameters whenever WordPress
does. Use
ServiceFactory::set_custom_*()with mocks (e.g. PHPUnit'screateMock()) for tests. - Deprecated WordPress functions stay available and are marked
@deprecated.
Contributing
See AGENTS.md for the layout, conventions and checks. Run composer check before opening a pull request.
merkushin/wpplugin uses WPAL: https://github.com/merkushin/wpplugin/blob/main/src/Wpplugin.php