{"id":49582859,"url":"https://github.com/devuri/wp-adapter","last_synced_at":"2026-05-03T21:01:15.859Z","repository":{"id":355405111,"uuid":"1227756671","full_name":"devuri/wp-adapter","owner":"devuri","description":null,"archived":false,"fork":false,"pushed_at":"2026-05-03T12:22:36.000Z","size":1203,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-03T14:21:02.707Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/devuri.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":".github/CODEOWNERS","security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-03T05:53:58.000Z","updated_at":"2026-05-03T12:21:39.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/devuri/wp-adapter","commit_stats":null,"previous_names":["devuri/wp-adapter"],"tags_count":2,"template":false,"template_full_name":"devuri/zipit","purl":"pkg:github/devuri/wp-adapter","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/devuri%2Fwp-adapter","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/devuri%2Fwp-adapter/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/devuri%2Fwp-adapter/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/devuri%2Fwp-adapter/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/devuri","download_url":"https://codeload.github.com/devuri/wp-adapter/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/devuri%2Fwp-adapter/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32584651,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-03T06:36:36.687Z","status":"ssl_error","status_checked_at":"2026-05-03T06:36:09.306Z","response_time":103,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-05-03T21:00:45.486Z","updated_at":"2026-05-03T21:01:15.818Z","avatar_url":"https://github.com/devuri.png","language":"PHP","funding_links":[],"categories":[],"sub_categories":[],"readme":"# WP Adapter\n\nWordPress adapter contracts and in-memory testing doubles for clean, testable plugin development.\n\n```bash\ncomposer require --dev devuri/wp-adapter\n```\n\n\n## The problem this solves\n\nWordPress plugins commonly call `get_option()`, `add_action()`, and `wp_remote_post()` directly inside business logic. That makes the logic impossible to unit test without bootstrapping WordPress, and it makes the plugin hard to reason about.\n\nWP Adapter gives us a thin set of contracts for common WordPress APIs and matching in-memory implementations for tests. Our plugin code depends only on the contracts. WordPress stays at the edge.\n\n```php\n// Business logic depends on the contract, not WordPress\nfinal class LicenseService\n{\n    private OptionStorageInterface $options;\n    private HttpClientInterface    $http;\n    private LoggerInterface        $logger;\n\n    public function __construct(\n        OptionStorageInterface $options,\n        HttpClientInterface    $http,\n        LoggerInterface        $logger\n    ) {\n        $this-\u003eoptions = $options;\n        $this-\u003ehttp    = $http;\n        $this-\u003elogger  = $logger;\n    }\n\n    public function activate(string $key): Result\n    {\n        // Pure logic. No WordPress functions. Fully unit-testable.\n    }\n}\n```\n\nIn production we pass the WordPress adapters. In tests we pass the in-memory fakes. No mocks. No bootstrapping WordPress.\n\n\n\n## Our plugin must follow the boundary rule\n\n**This package cannot help us if our business logic calls WordPress functions directly.** The adapters are only useful when our plugin is structured so that service classes receive their dependencies through the constructor as contracts.\n\nThe rule: WordPress function calls (`get_option`, `add_action`, `wp_remote_post`, etc.) belong only in the thin adapter classes that implement the contracts. Every other class must call only the interface, never WordPress.\n\nIf we call `get_option()` inside a service, that service requires WordPress to exist and cannot be unit tested. The testing adapters in this package will have no effect.\n\nSee **[docs/testing-guide.md](docs/testing-guide.md)** for the full structure, a wrong-vs-right example, PHPUnit setup, and a checklist.\n\n\n\n## Installation\n\nInstall as a dev dependency during development:\n\n```bash\ncomposer require --dev devuri/wp-adapter\n```\n\nCopy the source into our plugin at build time:\n\n```bash\nvendor/bin/wp-adapter-copy\n```\n\nThis copies `src/` and `psr/log` into `lib/wp-adapter/` inside our plugin. Load it from our plugin's main file:\n\n```php\nrequire_once __DIR__ . '/lib/wp-adapter/init.php';\n```\n\nStrip `vendor/` before distributing. `lib/` ships with the plugin. See [Direct-load distribution](#direct-load-distribution) for the full workflow.\n\n\n\n## Wiring production adapters\n\n```php\nuse AdapterKit\\Core\\PluginContext;\nuse AdapterKit\\Core\\Hooks\\WordPressHooks;\nuse AdapterKit\\Core\\Storage\\WordPressOptionStorage;\nuse AdapterKit\\Core\\Storage\\WordPressTransientStorage;\nuse AdapterKit\\Core\\Http\\WordPressHttpClient;\nuse AdapterKit\\Core\\Logging\\NullLogger;\n\n$context = PluginContext::fromPluginFile(\n    __FILE__, 'my-plugin', '1.0.0', 'my-plugin', 'myplugin_'\n);\n\n$plugin = new MyPlugin\\Plugin(\n    $context,\n    new WordPressHooks(),\n    new WordPressOptionStorage(),\n    new WordPressTransientStorage(),\n    new WordPressHttpClient(),\n    new NullLogger()\n);\n\n$plugin-\u003eregister();\n```\n\n\n\n## Unit testing without WordPress\n\nSwap in the in-memory testing adapters. No WordPress bootstrap required.\n\n```php\nuse AdapterKit\\Core\\Testing\\InMemoryOptionStorage;\nuse AdapterKit\\Core\\Testing\\MockHttpClient;\nuse AdapterKit\\Core\\Testing\\RecordingLogger;\n\nfinal class LicenseServiceTest extends TestCase\n{\n    private InMemoryOptionStorage $options;\n    private MockHttpClient        $http;\n    private RecordingLogger       $logger;\n    private LicenseService        $service;\n\n    protected function setUp(): void\n    {\n        $this-\u003eoptions = new InMemoryOptionStorage(['myplugin_license' =\u003e []]);\n        $this-\u003ehttp    = new MockHttpClient();\n        $this-\u003elogger  = new RecordingLogger();\n        $this-\u003eservice = new LicenseService(\n            $this-\u003eoptions, $this-\u003ehttp, $this-\u003elogger, 'myplugin_license'\n        );\n    }\n\n    public function test_activate_stores_key_on_success(): void\n    {\n        $this-\u003ehttp-\u003eaddJsonResponse('/activate', ['ok' =\u003e true], 200);\n\n        $result = $this-\u003eservice-\u003eactivate('VALID-KEY-123');\n\n        $this-\u003eassertTrue($result-\u003eisSuccess());\n        $stored = $this-\u003eoptions-\u003eget('myplugin_license');\n        $this-\u003eassertTrue($stored['active']);\n        $this-\u003eassertSame('VALID-KEY-123', $stored['key']);\n    }\n\n    public function test_activate_returns_failure_and_logs_warning_on_http_error(): void\n    {\n        $this-\u003ehttp-\u003eaddErrorResponse('/activate', 'Connection refused.');\n\n        $result = $this-\u003eservice-\u003eactivate('ANY-KEY');\n\n        $this-\u003eassertFalse($result-\u003eisSuccess());\n        $this-\u003eassertSame('activation_failed', $result-\u003egetCode());\n        $this-\u003eassertTrue($this-\u003elogger-\u003ehasWarning('activation_failed'));\n    }\n}\n```\n\n### PHPUnit bootstrap (`tests/bootstrap.php`)\n\n```php\n\u003c?php\n// WordPress is NOT loaded.\nrequire_once dirname(__DIR__) . '/vendor/autoload.php';\n```\n\nOne line. Composer's autoloader includes `devuri/wp-adapter` and `psr/log`. All contracts and testing adapters are available. No WordPress, no `WP_TESTS_DIR`.\n\n### PHPUnit config (`phpunit.xml.dist`)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003cphpunit bootstrap=\"tests/bootstrap.php\"\n         defaultTestSuite=\"Unit\"\n         colors=\"true\"\u003e\n\n    \u003ctestsuites\u003e\n        \u003ctestsuite name=\"Unit\"\u003e\n            \u003cdirectory\u003etests/Unit\u003c/directory\u003e\n        \u003c/testsuite\u003e\n        \u003ctestsuite name=\"Integration\"\u003e\n            \u003cdirectory\u003etests/Integration\u003c/directory\u003e\n        \u003c/testsuite\u003e\n    \u003c/testsuites\u003e\n\n    \u003ccoverage\u003e\n        \u003cinclude\u003e\n            \u003cdirectory suffix=\".php\"\u003esrc\u003c/directory\u003e\n        \u003c/include\u003e\n    \u003c/coverage\u003e\n\n\u003c/phpunit\u003e\n```\n\n`defaultTestSuite=\"Unit\"` ensures `vendor/bin/phpunit` never loads the integration suite. Integration tests (those that require WordPress) must be marked `@group integration` and run explicitly:\n\n```bash\n# Unit only (default — no WordPress needed)\nvendor/bin/phpunit --testdox\n\n# Integration only (requires WP_TESTS_DIR)\nWP_TESTS_DIR=/path/to/wordpress-tests-lib vendor/bin/phpunit --testsuite Integration\n```\n\nSee `examples/plugin-wiring/` for a complete, runnable example with service class, plugin class, and tests.\n\n\n## Testing adapters\n\nAll testing adapters live in `AdapterKit\\Core\\Testing\\` and are public, versioned API.\n\n### `InMemoryOptionStorage`\n\n```php\n$options = new InMemoryOptionStorage(['myplugin_settings' =\u003e ['enabled' =\u003e true]]);\n$options-\u003eupdate('myplugin_settings', ['enabled' =\u003e false]);\n$options-\u003ehas('myplugin_settings');  // true\n$options-\u003eall();                      // full store contents\n$options-\u003eclear();\n```\n\n### `InMemoryTransientStorage` + `FrozenClock`\n\n```php\n$clock      = new FrozenClock(1700000000);\n$transients = new InMemoryTransientStorage($clock);\n$transients-\u003eset('token', 'abc123', 60);\n$transients-\u003eget('token');   // 'abc123'\n$clock-\u003eadvance(61);\n$transients-\u003eget('token');   // false — expired\n```\n\n### `MockHttpClient`\n\n```php\n$http = new MockHttpClient();\n$http-\u003eaddJsonResponse('/activate', ['ok' =\u003e true], 200);\n$http-\u003eaddErrorResponse('/timeout', 'Request timed out.');\n\n$http-\u003epost('https://api.example.com/activate', []);\n\n$http-\u003ewasRequestMadeTo('/activate');  // true\n$http-\u003egetLastRequest();               // ['method' =\u003e 'POST', 'url' =\u003e ..., ...]\n$http-\u003egetRequestCount();              // 1\n```\n\n### `RecordingHooks`\n\n```php\n$hooks = new RecordingHooks();\n$plugin-\u003eregister($hooks);\n\n$hooks-\u003ehasAction('admin_menu');                     // bool\n$hooks-\u003ehasFilter('the_content');                    // bool\n$hooks-\u003ehasRestRoute('/my-plugin/v1/settings');      // bool\n$hooks-\u003egetActions();                                // array of all recorded actions\n```\n\n### `RecordingLogger`\n\n```php\n$logger = new RecordingLogger();\n$service-\u003erun($logger);\n\n$logger-\u003ehasWarning('rate_limit_exceeded');  // bool\n$logger-\u003ehasError('activation_failed');      // bool\n$logger-\u003egetErrors();                        // array\n$logger-\u003ecount('info');                      // int\n$logger-\u003eclear();\n```\n\n### `MockEnvironment`\n\n```php\n$env = new MockEnvironment(\n    'https://example.com',\n    'https://example.com/wp-admin/',\n    1700000000\n);\n\n$env-\u003ehomeUrl('pricing');\n$env-\u003eadminUrl('admin.php?page=my-plugin');\n$env-\u003esetCurrentScreenId('settings_page_my-plugin');\n$env-\u003esanitizeTextField(' hello world ');  // 'hello world'\n```\n\n\n## Contracts\n\nSix interfaces in `AdapterKit\\Core\\Contracts\\`. Our plugin code depends only on these.\n\n| Contract | Production adapter | Testing adapter |\n|---|---|---|\n| `HooksInterface` | `WordPressHooks` | `RecordingHooks` |\n| `OptionStorageInterface` | `WordPressOptionStorage` | `InMemoryOptionStorage` |\n| `TransientStorageInterface` | `WordPressTransientStorage` | `InMemoryTransientStorage` |\n| `EnvironmentInterface` | `WordPressEnvironment` | `MockEnvironment` |\n| `HttpClientInterface` | `WordPressHttpClient` | `MockHttpClient` |\n| `ClockInterface` | `SystemClock` | `FrozenClock` |\n\n`LoggerInterface` is `Psr\\Log\\LoggerInterface`. `NullLogger` and `WordPressDebugLogger` are the production implementations.\n\n\n## Shared value types\n\n**`PluginContext`** — immutable plugin metadata populated once at bootstrap.\n\n```php\n$ctx = PluginContext::fromPluginFile(__FILE__, 'my-plugin', '1.0.0', 'my-plugin', 'myplugin_');\n\n$ctx-\u003egetSlug();          // 'my-plugin'\n$ctx-\u003egetVersion();       // '1.0.0'\n$ctx-\u003egetDirPath();       // absolute path with trailing slash\n$ctx-\u003egetDirUrl();        // URL with trailing slash\n$ctx-\u003egetOptionPrefix();  // 'myplugin_'\n```\n\n**`Result`** — shared return type for service methods.\n\n```php\n$result = Result::success(['saved' =\u003e true]);\n$result = Result::failure('invalid_key', 'The license key is not valid.');\n\n$result-\u003eisSuccess();   // bool\n$result-\u003egetCode();     // string\n$result-\u003egetMessage();  // string\n$result-\u003egetData();     // array\n```\n\n**`KeyBuilder`** — prevents option/transient/hook naming drift.\n\n```php\n$keys = new KeyBuilder('myplugin_');\n$keys-\u003eoption('settings');    // myplugin_settings\n$keys-\u003etransient('token');    // myplugin_token\n$keys-\u003ehook('activated');     // myplugin_/activated\n```\n\n\n## Direct-load distribution\n\nWordPress plugins are distributed as ZIP files without a Composer runtime. WP Adapter supports this out of the box.\n\n**Development workflow:**\n\n```bash\n# 1. Install as a dev dependency\ncomposer require --dev devuri/wp-adapter\n\n# 2. Copy into lib/ (run this at build time, not at runtime)\nvendor/bin/wp-adapter-copy\n\n# 3. Load in our plugin's main file\n# require_once __DIR__ . '/lib/wp-adapter/init.php';\n\n# 4. Strip vendor/ before packaging. lib/ ships with the plugin.\n```\n\n`wp-adapter-copy` copies `src/` and a PHP 7.4-safe copy of `psr/log` into `lib/wp-adapter/`. The `init.php` entry point registers two PSR-4 autoloaders — one for `AdapterKit\\Core\\` and one for `Psr\\Log\\` — so no Composer is needed on the end user's server.\n\n**Do not use a `class_exists` guard:**\n\n```php\n// Wrong — silently accepts the first loaded version if multiple plugins use this package\nif (! class_exists(AdapterKit\\Core\\Result::class)) {\n    require_once __DIR__ . '/lib/wp-adapter/init.php';\n}\n\n// Correct — load unconditionally\nrequire_once __DIR__ . '/lib/wp-adapter/init.php';\n```\n\nNamespace-per-plugin scoping is deferred to a future build step.\n\n\n## Requirements\n\n| | |\n|---|---|\n| PHP | 7.4, 8.0, 8.1, 8.2 |\n| WordPress | No minimum enforced |\n| Dependencies | `psr/log ^1.1` (runtime) |\n\nThe package is deliberately PHP 7.4 compatible throughout. `mixed` type hints, constructor property promotion, union types, and all other PHP 8.0+ syntax are forbidden in `src/`.\n\n\n\n## Further reading\n\n- [docs/testing-guide.md](docs/testing-guide.md) boundary rule, wrong-vs-right examples, PHPUnit setup, checklist\n- [docs/architecture.md](docs/architecture.md) three-layer design, contract table, PSR adoption scope\n- [docs/direct-load.md](docs/direct-load.md) full direct-load distribution workflow\n- [docs/compatibility.md](docs/compatibility.md) PHP version matrix, forbidden syntax, PSR-3 pin rationale\n- [examples/plugin-wiring/](examples/plugin-wiring/) complete example with service, plugin class, and unit tests\n\n\n## License\n\nThis project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdevuri%2Fwp-adapter","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fdevuri%2Fwp-adapter","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdevuri%2Fwp-adapter/lists"}