diff --git a/.github/instructions/php.instructions.md b/.github/instructions/php.instructions.md
index 7dea226..e2b43ba 100644
--- a/.github/instructions/php.instructions.md
+++ b/.github/instructions/php.instructions.md
@@ -8,7 +8,7 @@ description: "Framework-development rules for rtcamp/wp-framework PHP."
## Layout & contracts
- `inc/Contracts/Interfaces/`: `Registrable`, `ConditionallyRegistrable`, `Shareable`, `CLICommand`.
-- `inc/Contracts/Abstracts/`: `AbstractModule`, `AbstractPostType`, `AbstractTaxonomy`, `AbstractBlock`, `AbstractShortcode`, `AbstractRESTController`, `AbstractSettingsPage`, `AbstractAdminPage`, `AbstractUserRole`.
+- `inc/Contracts/Abstracts/`: `AbstractModule`, `AbstractFeature`, `AbstractPostType`, `AbstractTaxonomy`, `AbstractBlock`, `AbstractShortcode`, `AbstractRESTController`, `AbstractSettingsPage`, `AbstractAdminPage`, `AbstractUserRole`.
- `inc/Contracts/Traits/`: `Loader`, `Singleton`.
- `inc/` root: `Container`, `AssetLoader`, `ComponentLoader`, `TemplateLoader`; `inc/Utils/`: utilities (e.g. `Encryptor`).
diff --git a/AGENTS.md b/AGENTS.md
index 3232365..c09e24b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
# AGENTS.md — wp-framework
-Tool-agnostic brief for AI coding agents (Claude Code, Copilot coding agent, Codex). `rtcamp/wp-framework`: shared base contracts (interfaces, abstracts, traits) and small utilities consumed via Composer by every rtCamp plugin/theme skeleton. **Zero runtime dependencies.** PHP 8.2+, WordPress 6.5+ — the floor is set by the Script Modules API (`wp_register_script_module()`, new in 6.5). The only API used above 6.5 is `wp_register_block_types_from_metadata_collection()` (6.8+), and `AssetLoader::register_block_manifest()` guards it with a per-block fallback for 6.5–6.7.
+Tool-agnostic brief for AI coding agents (Claude Code, Copilot coding agent, Codex). `rtcamp/wp-framework`: shared base contracts (interfaces, abstracts, traits) and small utilities consumed via Composer by every rtCamp plugin/theme skeleton. **Zero Composer runtime dependencies.** PHP 8.2+, WordPress 6.5+ — the floor is set by the Script Modules API (`wp_register_script_module()`, new in 6.5). The only API used above 6.5 is `wp_register_block_types_from_metadata_collection()` (6.8+), and `AssetLoader::register_block_manifest()` guards it with a per-block fallback for 6.5–6.7. `Encryptor` requires the OpenSSL PHP extension when used.
## Authoritative rules
@@ -10,7 +10,7 @@ Tool-agnostic brief for AI coding agents (Claude Code, Copilot coding agent, Cod
## Key principles (full detail in the files above)
- **`inc/Contracts/` is public API.** Interfaces, abstracts, and their method signatures are consumed by every plugin/theme: a signature change breaks all of them. Treat such changes as breaking.
-- **Zero runtime deps**: `composer.json` `require` holds only `php`; everything else is `require-dev`.
+- **Zero Composer runtime deps**: `composer.json` `require` holds only `php`; everything else is `require-dev`.
- **TDD**: failing PHPUnit test first (`tests/` mirrors `inc/`), then code.
- **Tests run against real WordPress via wp-env** — no WP function mocking. `npm run wp-env start` then `npm run test:php` (a `pretest:php` hook runs `composer install` in the container first). WP-dependent tests extend `rtCamp\WPFramework\Tests\TestCase` (a `WP_UnitTestCase`); pure-logic tests can stay on `PHPUnit\Framework\TestCase`. CI runs a PHP × WP matrix (PHP 8.2+, WP 6.5+).
- `declare( strict_types = 1 );`, full types, `@package`/`@since`, `static::` not `self::`, PSR-4 (`rtCamp\WPFramework\` → `inc/`).
@@ -18,7 +18,7 @@ Tool-agnostic brief for AI coding agents (Claude Code, Copilot coding agent, Cod
## Structure
-`inc/Contracts/{Interfaces,Abstracts,Traits}/` (the consumed contract surface), `inc/` root (`Container`, `AssetLoader`, `ComponentLoader`), `inc/Utils/`. `ai/` holds the canonical consumer instruction doc; `bin/` holds the sync tool.
+`inc/Contracts/{Interfaces,Abstracts,Traits}/` (the consumed contract surface), `inc/` root (`Container`, `AssetLoader`, `ComponentLoader`, `TemplateLoader`), `inc/Utils/`. `ai/` holds the canonical consumer instruction doc; `bin/` holds the sync tool.
## This repo also ships tooling for consumers
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2435807..fc624ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
-Nothing yet.
+### Documentation
+
+- Added implementor getting-started and maintainer workflow guides; corrected
+ Singleton, REST-controller, compatibility, and wp-env test guidance; expanded
+ loader, cache, feature-selector, timer, and utility API coverage.
+- Added `docs/upgrading.md` (versioning promise and the 1.0.0 → 1.0.1 `Singleton`
+ migration) and `docs/troubleshooting.md` (symptom → cause for the framework's
+ exceptions, `_doing_it_wrong()` notices, and silent no-ops).
+- Documented the real install path: the package is not on public Packagist, so
+ the consumer needs a VCS `repositories` entry and a `^1.0` constraint.
+- Added a worked WP-CLI example to `docs/contracts.md`, the only contract that
+ had none, and a quick-look snippet to the README.
+- Corrected the `Loader::load()` snippet in `docs/architecture.md` to match the
+ implementation, and the `AbstractSettingsPage` capability note in
+ `docs/abstracts.md` (the `option_page_capability_*` filter is unconditional).
## [1.0.1] - 2026-07-29
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index fefe6e0..60936f9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -17,20 +17,22 @@ treated as stable and changes to it are considered breaking.
## Development setup
```bash
-# 1. Install PHP dev dependencies
+# Host tooling (PHPCS and PHPStan).
composer install
-# 2. Bring up WordPress for the integration tests (Docker required)
-npm install
-npm run wp-env start # starts @wordpress/env
+# WordPress integration tests (Docker required).
+npm ci
+npm run wp-env start
```
## Before you open a PR
-Run the full check suite locally — all of it must exit `0`:
+Run all three checks locally — all of them must exit `0`:
```bash
-composer check # PHPCS (lint) + PHPStan (analyse) + PHPUnit (test)
+composer lint
+composer analyse
+npm run test:php
```
Individual steps:
@@ -39,17 +41,25 @@ Individual steps:
composer lint # PHPCS against WordPress Coding Standards
composer lint:fix # auto-fix fixable violations
composer analyse # PHPStan static analysis
-composer test # PHPUnit
+npm run test:php # PHPUnit in the wp-env test container
```
-Tests run against real WordPress via `@wordpress/env`. Follow TDD: add a failing
-test under `tests/` (which mirrors `inc/`) first, then the implementation.
+Tests run against real WordPress via `@wordpress/env`. The `pretest:php` script
+installs Composer dependencies inside the container before PHPUnit runs.
+`composer test` is the lower-level host command and requires a separately
+configured WordPress test suite and database; it is not the default local path.
+
+Follow TDD: add a failing test under `tests/` (which mirrors `inc/`) first, then
+the implementation. See [docs/maintainers.md](docs/maintainers.md) for test-case,
+contract-change, and documentation guidance.
## Pull request checklist
-- [ ] `composer check` passes (lint + analyse + test, all green).
+- [ ] `composer lint`, `composer analyse`, and `npm run test:php` pass.
- [ ] New/changed behavior is covered by tests.
- [ ] Any change to `inc/Contracts/` is flagged as breaking in the PR description.
+- [ ] Contract changes are reflected in `ai/framework-php.instructions.md`.
+- [ ] User-visible behavior is reflected in `README.md` or `docs/`.
- [ ] A `CHANGELOG.md` entry is added under `## [Unreleased]`.
- [ ] Commits follow [Conventional Commits](https://www.conventionalcommits.org/).
diff --git a/README.md b/README.md
index 8ae6563..9738bed 100644
--- a/README.md
+++ b/README.md
@@ -17,16 +17,63 @@
`wp-framework` is a **library, not a plugin**. It ships contracts (interfaces,
abstracts, traits) plus concrete loaders and utilities; consuming plugins and
-themes build their features on top. **Zero runtime dependencies. PHP 8.2+.**
+themes build their features on top. It has **zero Composer runtime dependencies**.
+
+Requirements:
+
+- PHP 8.2+
+- WordPress 6.5+
+- Composer
+- The OpenSSL PHP extension when using `Encryptor`
## Install
+Not on public Packagist — add the repository to the consuming project's
+`composer.json`, then require it with a caret constraint:
+
+```json
+{
+ "repositories": [
+ { "type": "vcs", "url": "https://github.com/rtCamp/wp-framework" }
+ ]
+}
+```
+
```bash
-composer require rtcamp/wp-framework
+composer require rtcamp/wp-framework:^1.0
```
+(The `repositories` entry is unnecessary when the project already resolves this
+package through an rtCamp-hosted Composer registry.)
+
PSR-4 autoloading: `rtCamp\WPFramework\` → `inc/`.
+## Quick look
+
+```php
+use rtCamp\WPFramework\Contracts\Abstracts\AbstractPostType;
+use rtCamp\WPFramework\Contracts\Traits\Loader;
+
+final class ArticlePostType extends AbstractPostType {
+ public static function get_slug(): string { return 'article'; }
+ public function get_singular_label(): string { return __( 'Article', 'acme' ); }
+ public function get_plural_label(): string { return __( 'Articles', 'acme' ); }
+ public function get_menu_icon(): string { return 'dashicons-media-document'; }
+}
+
+final class Main {
+ use Loader;
+
+ public function boot(): void {
+ $this->load( [ ArticlePostType::class ] ); // instantiates + registers hooks
+ }
+}
+```
+
+A registered, REST-enabled post type with no `register_post_type()` call and no
+`init` hook written by hand. Full walkthrough in
+[docs/getting-started.md](docs/getting-started.md).
+
## What's inside
- **Registration core** — the spine every consumer boots through:
@@ -48,6 +95,9 @@ PSR-4 autoloading: `rtCamp\WPFramework\` → `inc/`.
- `Cache` — typed wrapper over the WP object cache, group-namespaced, optional SWR
- `FeatureSelector` + `FeatureSelectorSettingsPage` — a fail-closed feature-flag
registry and its admin toggle page
+ - `Logger` — context-prefixed, `WP_DEBUG`-gated logging
+ - `Timer` — named request-scoped timers and laps
+ - `Transients` — prefix-namespaced transient storage
The contract surface (`inc/Contracts/`) is the public API: every interface,
abstract, and signature there is consumed by dependents, so changes to it are
@@ -67,23 +117,33 @@ Start with [docs/index.md](docs/index.md), then:
| Doc | What it covers |
|---|---|
+| [getting-started.md](docs/getting-started.md) | Install, bootstrap a plugin or theme, load a module, and share a service. |
| [architecture.md](docs/architecture.md) | How a class becomes a live hook — the `Registrable` → `Loader` → `Container` flow. Read first. |
| [contracts.md](docs/contracts.md) | The interfaces and traits in detail. |
| [abstracts.md](docs/abstracts.md) | Cookbook for the `Abstract*` base classes. |
| [loaders.md](docs/loaders.md) | `AssetLoader`, `ComponentLoader`, `TemplateLoader` and the theme-override hierarchy. |
-| [utilities.md](docs/utilities.md) | `Encryptor`, `Cache`, `FeatureSelector`, and `Container`. |
+| [utilities.md](docs/utilities.md) | `Encryptor`, `Cache`, feature flags, logging, transients, timers, and `Container`. |
+| [upgrading.md](docs/upgrading.md) | What changes between releases and what a consumer has to do about it. |
+| [troubleshooting.md](docs/troubleshooting.md) | Symptom → cause for the errors and silent no-ops the framework emits. |
+| [ai-review-system.md](docs/ai-review-system.md) | How the AI review instructions are authored here and synced into the skeletons. |
+| [maintainers.md](docs/maintainers.md) | Development environment, tests, change checklist, and documentation maintenance. |
## Development
```bash
-composer install # PHP dev dependencies
-composer check # lint (PHPCS) + analyse (PHPStan) + test (PHPUnit)
+composer install
+npm ci
+npm run wp-env start
+composer lint
+composer analyse
+npm run test:php
```
-Tests run against real WordPress via [`@wordpress/env`](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/)
-(`npm install && npm run wp-env start`). TDD: a failing test first (`tests/` mirrors
-`inc/`), then the code. Conventions live in [AGENTS.md](AGENTS.md), shared across
-all contributors and AI tools.
+Tests run against real WordPress via [`@wordpress/env`](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/).
+Use `npm run test:php`, which runs PHPUnit inside the wp-env test container;
+`composer test` only works directly when a host WordPress test suite has been
+configured. See [docs/maintainers.md](docs/maintainers.md) for the complete
+workflow.
## Contributing
diff --git a/ai/framework-php.instructions.md b/ai/framework-php.instructions.md
index bbfd473..3fddef2 100644
--- a/ai/framework-php.instructions.md
+++ b/ai/framework-php.instructions.md
@@ -16,7 +16,7 @@ Decision order for a new class, **do NOT default to Singleton**:
2. **`Registrable` + `Shareable`**: only if another class must retrieve it via `get_shared()`.
3. **`Singleton`**: only the `Main` bootstrap.
-Extend the framework abstracts; never hand-roll their job: `AbstractModule` and `Abstract{PostType,Taxonomy,Block,Shortcode,RESTController,SettingsPage,AdminPage,UserRole}`.
+Extend the framework abstracts; never hand-roll their job: `AbstractModule`, `AbstractFeature`, and `Abstract{PostType,Taxonomy,Block,Shortcode,RESTController,SettingsPage,AdminPage,UserRole}`.
Flag genuine contract/security violations, not style. Allow any correct implementation.
diff --git a/docs/abstracts.md b/docs/abstracts.md
index fef3cc8..045cb7c 100644
--- a/docs/abstracts.md
+++ b/docs/abstracts.md
@@ -226,8 +226,8 @@ Because it's a real `WP_REST_Controller`, all the core helper methods
(`get_items_permissions_check()`, schema helpers, …) are available to override.
> **Implementation note.** The base declares `register_routes()` as a concrete
-> method that throws a "not implemented" `Exception` (the message is prefixed with
-> the method name) rather than as `abstract`. The
+> method that throws a `LogicException` naming the concrete class and method,
+> rather than as `abstract`. The
> effect is "you must override it," but the failure surfaces at **runtime** (when
> `rest_api_init` fires), not at class-load time. Always provide your own
> `register_routes()`. (An `abstract` method would catch a missing override at
@@ -278,6 +278,14 @@ subclass — it re-declares the same menu seams). It registers on **three** hook
base loops it and calls `register_setting( $this->get_option_group(), … )` for
each. The option group defaults to the page slug.
+`get_menu_slug()` also defaults to the page slug and can be overridden when the
+menu slug depends on instance state. The base always filters
+`option_page_capability_{group}` for this page's option group to return
+`get_capability()`, so the menu, the render callback, and the `options.php` save
+are authorized by the same capability. That matters when `get_capability()` is
+lowered from its `manage_options` default: without the filter the page would
+render for the lower capability but silently fail to save.
+
```php
final class SettingsPage extends AbstractSettingsPage {
public static function get_slug(): string { return 'my-plugin-settings'; }
diff --git a/docs/architecture.md b/docs/architecture.md
index dbfab0b..0bbff6c 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -24,17 +24,23 @@ debuggable by reading `Loader::load()` top to bottom.
[`Loader::load()`](../inc/Contracts/Traits/Loader.php) is the heart of the
framework. Given `class-string[]`, for each class it:
-1. **Instantiates** it with `new $class_name()` — every loadable class must be
+1. **Skips a duplicate** when the same class name already appeared in this load.
+2. **Instantiates** it with `new $class_name()` — every loadable class must be
constructible with no arguments (i.e. no *required* constructor parameters;
all-optional is fine).
-2. **Registers hooks** if it is `Registrable` — but first, if it is also
+3. **Registers hooks** if it is `Registrable` — but first, if it is also
`ConditionallyRegistrable`, it calls `can_register()` and skips registration
when that returns `false`.
-3. **Caches the instance** if it is `Shareable`, storing it in a `Container`
+4. **Caches the instance** if it is `Shareable`, storing it in a `Container`
keyed by class name.
```php
foreach ( $classes as $class_name ) {
+ if ( isset( $seen[ $class_name ] ) ) {
+ continue;
+ }
+ $seen[ $class_name ] = true;
+
$instance = new $class_name();
if ( $instance instanceof Registrable ) {
@@ -60,6 +66,9 @@ them, not to a global. Call `get_shared( $class_name )` on that same loader to
retrieve one; calling it before `load()` — or for a class that wasn't
`Shareable` — throws a `RuntimeException`.
+Because another `load()` replaces the container, use one complete class list per
+loader. A second call does not add to the previously shared set.
+
## Modules: loaders that hold loaders
Most skeletons don't hand the top-level loader a flat list of services. They hand
@@ -135,7 +144,9 @@ they are not the same thing:
default.
- **`Singleton` trait** — global `ClassName::get_instance()` access with cloning
and deserialization guarded. Use it only when something truly must be a process
- global and you can't thread it through a loader.
+ global and you can't thread it through a loader. A class and its subclasses
+ share the trait's single storage slot, so do not resolve a child of a singleton
+ through `get_instance()`.
If you can pass the object in a constructor instead, do that. The
`ComponentLoader`/`TemplateLoader` pattern (a `Shareable` subclass fetched via
diff --git a/docs/contracts.md b/docs/contracts.md
index f6d1a71..a41b316 100644
--- a/docs/contracts.md
+++ b/docs/contracts.md
@@ -92,6 +92,63 @@ from a `Registrable`'s `register_hooks()` behind a `WP_CLI` check), using these
methods to supply the name, description, and callback. It standardises the
*shape* of a command across skeletons, not its registration.
+The command itself is a plain static class:
+
+```php
+use rtCamp\WPFramework\Contracts\Interfaces\CLICommand;
+
+final class ReindexCommand implements CLICommand {
+ public static function get_name(): string {
+ return 'acme reindex';
+ }
+
+ public static function get_description(): string {
+ return 'Rebuild the Acme search index.';
+ }
+
+ /**
+ * @param string[] $args Positional arguments.
+ * @param array $assoc_args Flags.
+ */
+ public static function run( array $args, array $assoc_args ): void {
+ \WP_CLI::log( 'Reindexing…' );
+ \WP_CLI::success( 'Done.' );
+ }
+}
+```
+
+Registration is the consumer's, and `ConditionallyRegistrable` is the natural
+place for the `WP_CLI` check — the loader then skips the class entirely outside
+WP-CLI instead of the class guarding itself:
+
+```php
+use rtCamp\WPFramework\Contracts\Interfaces\ConditionallyRegistrable;
+
+final class CliCommands implements ConditionallyRegistrable {
+ /** @var array> */
+ private const COMMANDS = [ ReindexCommand::class ];
+
+ public function can_register(): bool {
+ return defined( 'WP_CLI' ) && \WP_CLI;
+ }
+
+ public function register_hooks(): void {
+ foreach ( self::COMMANDS as $command ) {
+ \WP_CLI::add_command(
+ $command::get_name(),
+ [ $command, 'run' ],
+ [ 'shortdesc' => $command::get_description() ]
+ );
+ }
+ }
+}
+```
+
+`CliCommands` goes in a module's `get_classes()` like any other service. Because
+`WP_CLI::add_command()` is available as soon as WP-CLI has bootstrapped, no
+further hook is needed — registering directly from `register_hooks()` is correct
+here, unlike the `Abstract*` classes that must wait for `init`.
+
## Traits
### `Loader`
@@ -103,7 +160,7 @@ gain the ability to load other classes.
| Member | Visibility | Purpose |
|---|---|---|
-| `load( array $classes ): void` | `protected` | Instantiate each class; register hooks if `Registrable` (respecting `ConditionallyRegistrable`); cache if `Shareable`. Creates a fresh `Container` each call. |
+| `load( array $classes ): void` | `protected` | Instantiate each unique class; register hooks if `Registrable` (respecting `ConditionallyRegistrable`); cache if `Shareable`. Creates a fresh `Container` each call. |
| `get_shared( string $id ): object` | `public` | Return an instance previously cached as `Shareable`. Throws `RuntimeException` if `load()` hasn't run, or if `$id` was never cached as a `Shareable` in that load (not `Shareable`, or not among the loaded classes). |
| `$container` | `private Container` | The per-load instance store. Not accessible to consumers — go through `get_shared()`. |
@@ -111,6 +168,11 @@ Because `load()` is `protected`, only the class that `use`s the trait can start
load — you can't load from outside. That's why the entry point is a consumer's
own `Main` class and each `AbstractModule`, both of which `use Loader`.
+Duplicate names in one class list are loaded once, preventing duplicate hook
+registration. A subsequent call to `load()` creates a new container rather than
+extending the old one, so instances shared by a previous call are no longer
+retrievable from that loader.
+
### `Singleton`
[`inc/Contracts/Traits/Singleton.php`](../inc/Contracts/Traits/Singleton.php)
@@ -129,12 +191,18 @@ trait Singleton {
Points that matter:
-- It uses **late static binding** (`static::$instance`, `new static()`), so each
- class that uses the trait gets its **own** instance, not a shared one. This is
- why the framework's house rule is "`static::`, never `self::`" — `self::` here
- would collapse every singleton into one slot.
+- It uses **late static binding** (`static::$instance`, `new static()`), allowing
+ the using class to override or initialize the protected storage. Unrelated
+ classes that each use the trait have separate properties, but a class and its
+ subclasses share one storage slot. Do not call `get_instance()` on a subclass
+ of a singleton: whichever side is resolved first occupies the slot for both.
- The constructor is `protected` and empty; the using class overrides it to do
setup. Direct `new` is blocked.
+- `get_instance()` stores the instance after the constructor returns. If the
+ constructor performs work that can re-enter `get_instance()`—for example a
+ `Main` constructor that loads classes whose constructors reach back to
+ `Main`—assign `static::$instance = $this` as the constructor's first statement.
+ Otherwise the re-entrant call starts another construction.
- `__clone()` and `__wakeup()` are `final` and emit `_doing_it_wrong()` — the
instance can't be duplicated or revived through deserialization.
- The trait's docblock states up front that singletons are an anti-pattern;
diff --git a/docs/getting-started.md b/docs/getting-started.md
new file mode 100644
index 0000000..af3b637
--- /dev/null
+++ b/docs/getting-started.md
@@ -0,0 +1,198 @@
+# Getting started
+
+This guide shows the smallest complete integration: install the package, boot it
+from a plugin or theme, group services in a module, and retrieve a deliberately
+shared service.
+
+## Requirements
+
+- PHP 8.2 or newer
+- WordPress 6.5 or newer
+- Composer
+
+The package has no Composer runtime dependencies beyond PHP. It is nevertheless
+a WordPress library: its registration classes and most utilities call WordPress
+APIs. `Encryptor` additionally requires the OpenSSL PHP extension when used.
+
+## Install
+
+The package is not published on public Packagist. Add the repository to the
+consuming project's `composer.json` first, then require it:
+
+```json
+{
+ "repositories": [
+ {
+ "type": "vcs",
+ "url": "https://github.com/rtCamp/wp-framework"
+ }
+ ]
+}
+```
+
+```bash
+composer require rtcamp/wp-framework:^1.0
+```
+
+If the project is already wired to an rtCamp-hosted Composer registry that
+serves this package, the `repositories` entry is unnecessary and
+`composer require rtcamp/wp-framework:^1.0` is enough on its own.
+
+Pin with a caret constraint. `inc/Contracts/` is the public API and the project
+follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html), so `^1.0`
+accepts additive releases and refuses the next major. Read
+[upgrading.md](upgrading.md) before moving across a major.
+
+Composer exposes framework classes through the `rtCamp\WPFramework\` namespace.
+The consuming plugin or theme remains responsible for requiring its own Composer
+autoload file and starting its entry class.
+
+## 1. Create a service
+
+Use the narrowest contract that describes the service. Most hook-driven classes
+only need `Registrable`:
+
+```php
+' . esc_html__( 'Example is active.', 'acme-example' ) . '
';
+ }
+}
+```
+
+For post types, taxonomies, blocks, shortcodes, REST controllers, settings pages,
+admin pages, and user roles, extend the matching `Abstract*` class instead of
+repeating its registration plumbing. See [abstracts.md](abstracts.md).
+
+## 2. Group services in a module
+
+An `AbstractModule` is a loader for a related set of classes. Every listed class
+must be constructible without required constructor arguments.
+
+```php
+load( [ ContentModule::class ] );
+ }
+}
+
+Main::get_instance();
+```
+
+### Theme entry point
+
+The same `Main` shape works in a theme. Require the theme's Composer autoloader
+from `functions.php` and attach `boot()` to `after_setup_theme` instead of
+`plugins_loaded`.
+
+The early `static::$instance = $this` assignment is important when construction
+can re-enter `get_instance()`. A class using `Singleton` and its subclasses also
+share one storage slot; do not call `get_instance()` on a subclass of a singleton.
+See [contracts.md](contracts.md#singleton).
+
+## Sharing a service intentionally
+
+Classes are not retained by default. Add the `Shareable` marker only when another
+class must retrieve the exact instance that was loaded:
+
+```php
+use rtCamp\WPFramework\Contracts\Interfaces\Shareable;
+use rtCamp\WPFramework\Utils\Cache;
+
+final class PluginCache extends Cache implements Shareable {
+ public function __construct() {
+ parent::__construct( 'acme-example' );
+ }
+}
+
+final class InfrastructureModule extends AbstractModule {
+ protected function get_classes(): array {
+ return [ PluginCache::class ];
+ }
+
+ public function cache(): PluginCache {
+ return $this->get_shared( PluginCache::class );
+ }
+}
+```
+
+`get_shared()` belongs to the loader that loaded the class. In this example the
+cache is retrieved from `InfrastructureModule`, not from `Main`. If an outer
+loader must retrieve the module itself, the module must also implement
+`Shareable`.
+
+Prefer constructor injection when objects can be assembled directly. `Shareable`
+is for loader-created objects that genuinely need later retrieval; it should not
+be the default for every service.
+
+## What happens during load
+
+For each unique class name, the loader:
+
+1. constructs one instance;
+2. calls `can_register()` for a `ConditionallyRegistrable`;
+3. calls `register_hooks()` when registration is allowed;
+4. stores the instance when it is `Shareable`.
+
+Duplicate class names in one list are ignored. Each call to `load()` creates a
+fresh container, so a later call on the same loader replaces the previously
+shared set. Prefer one load per loader with the complete class list.
+
+Continue with [architecture.md](architecture.md) for the lifecycle model and
+[abstracts.md](abstracts.md) for implementation recipes. When something doesn't
+register, start at [troubleshooting.md](troubleshooting.md).
diff --git a/docs/index.md b/docs/index.md
index a7ae2f6..98fc430 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -17,7 +17,9 @@ Two rules define the whole package:
1. **A registration system.** A predictable way to turn a list of classes into
live WordPress hooks — `Registrable`, the `Loader` trait, and the
- `Container`. This is the spine; read [architecture.md](architecture.md) first.
+ `Container`. This is the spine; start with
+ [getting-started.md](getting-started.md), then read
+ [architecture.md](architecture.md).
2. **A library of base classes.** Ten `Abstract*` classes — most wrap one
WordPress registration chore (a post type, a taxonomy, a block, a settings
page, …); two are structural: `AbstractModule` groups services and
@@ -27,19 +29,23 @@ Two rules define the whole package:
across the child-theme → parent-theme → package hierarchy. See
[loaders.md](loaders.md).
4. **Utilities & services.** Context-scoped helpers a consumer holds or shares:
- `Encryptor`, `Cache`, and `FeatureSelector` (+ its settings page). See
- [utilities.md](utilities.md).
+ `Encryptor`, `Cache`, `FeatureSelector` (+ its settings page), `Logger`,
+ `Transients`, and `Timer`. See [utilities.md](utilities.md).
## Map of the docs
| Doc | What it covers |
|---|---|
+| [getting-started.md](getting-started.md) | A complete first integration: requirements, bootstrap, module, service, and shared-service retrieval. |
| [architecture.md](architecture.md) | The mental model: how a class becomes a live hook. The `Registrable` → `Loader` → `Container` flow and where `Module` fits. Start here. |
| [contracts.md](contracts.md) | Reference for the interfaces and traits: `Registrable`, `ConditionallyRegistrable`, `Shareable`, `CLICommand`, `Loader`, `Singleton`. |
| [abstracts.md](abstracts.md) | Cookbook for the ten `Abstract*` base classes — what each is for, the methods to implement, the hook it wires, a minimal subclass. |
| [loaders.md](loaders.md) | `AssetLoader`, `ComponentLoader`, `TemplateLoader` — the asset/render subsystem and the theme-override hierarchy they share. |
-| [utilities.md](utilities.md) | `Encryptor`, `Cache`, `FeatureSelector`, `FeatureSelectorSettingsPage`, and `Container`. |
-| [ai-review-system.md](ai-review-system.md) | How the AI review instructions are authored here and synced into the skeletons. |
+| [utilities.md](utilities.md) | `Encryptor`, `Cache`, `FeatureSelector`, its settings page, `Logger`, `Transients`, `Timer`, and `Container`. |
+| [upgrading.md](upgrading.md) | What changes between releases, and what a consumer has to do about it. |
+| [troubleshooting.md](troubleshooting.md) | Symptom → cause for the exceptions, `_doing_it_wrong()` notices, and silent no-ops the framework emits. |
+| [ai-review-system.md](ai-review-system.md) | How the AI review instructions are authored here and synced into the skeletons. *(maintainer/tooling doc)* |
+| [maintainers.md](maintainers.md) | How to set up, test, verify, and document changes to this repository. *(maintainer doc)* |
## How a skeleton uses it (the one-paragraph version)
diff --git a/docs/loaders.md b/docs/loaders.md
index 016b1ab..65e7857 100644
--- a/docs/loaders.md
+++ b/docs/loaders.md
@@ -77,6 +77,12 @@ An asset is then addressed by a path **relative to `$assets_dir`, without the
extension** — e.g. `register_script( 'my-app', 'app' )` looks for
`//app.js`.
+`get_base_dir()` returns the normalized, trailing-slashed package directory and
+`get_assets_dir()` returns the relative assets directory. These are useful when
+a subclass needs to resolve another build artifact. `handle( $name )` prefixes a
+short handle with `static::HANDLE_PREFIX`; override that constant in a consumer
+subclass to namespace all handles consistently.
+
### Registration methods
| Method | Registers | Returns |
@@ -91,6 +97,10 @@ These **register**; they don't enqueue. Call `wp_enqueue_script()` /
`wp_enqueue_style()` with the handle afterwards (the `ComponentLoader` does this
for you for component assets).
+Asset filenames may contain relative subdirectories but may not contain `..`.
+The extension must be a bare suffix without a leading dot or path separator.
+Unsafe paths are rejected before the filesystem is probed.
+
### The `*.asset.php` manifest
This is the convenience that makes `AssetLoader` worth using. The
@@ -196,6 +206,22 @@ method precisely so a component can't reach back into the loader. It can forward
- A missing component emits `_doing_it_wrong()` and renders nothing rather than
throwing a fatal error.
+### Component hooks
+
+With a context of `my-plugin`, the hooks use the following names and arguments:
+
+| Hook | Type | Arguments |
+|---|---|---|
+| `my-plugin/component_before_render` | action | `$name, $args, $context` |
+| `my-plugin/component_after_render` | action | `$name, $args, $context` |
+| `my-plugin/component_should_enqueue` | filter | `$enqueue, $name, $asset_type, $context` |
+| `my-plugin/component_asset_handle` | filter | `$handle, $name, $asset_type, $context` |
+
+`$asset_type` is `style` or `script`. The should-enqueue filter must return a
+boolean; the handle filter must return a string. Override
+`get_enqueue_settings()` to change the default `script` and `style` values for
+all renders from a loader.
+
---
## TemplateLoader
@@ -256,6 +282,23 @@ dominates the location.
- A missing template is a silent no-op (`render()` echoes nothing, `locate()`
returns `false`).
+### Template hooks
+
+For a prefix of `my_plugin` and the default `/` separator:
+
+| Hook | Type | Arguments / return value |
+|---|---|---|
+| `my_plugin/get_template_part_{slug}` | action | `$slug, $name, $args` |
+| `my_plugin/template_file_names` | filter | `array $templates, $slug, $name`; return candidate filenames |
+| `my_plugin/template_args` | filter | `array $args, $slug, $name`; return arguments passed to the template |
+| `my_plugin/template_paths` | filter | `array $paths`; return paths keyed by numeric priority |
+| `my_plugin/located_template` | filter | `$located, array $templates`; return a path or `false` |
+
+The request action and argument filter run only through `render()`/`get()`;
+`locate()` performs location filters without rendering. Exceptions thrown by a
+component or template propagate to the caller. The `get()` methods clean up
+their output buffer before rethrowing.
+
> **`TemplateLoader` vs. `ComponentLoader` — when to use which.** Use a
> **component** for a reusable, self-contained UI fragment with its own scoped
> CSS/JS and no dependence on the main query (a button, a card). Use a **template
diff --git a/docs/maintainers.md b/docs/maintainers.md
new file mode 100644
index 0000000..9e7d288
--- /dev/null
+++ b/docs/maintainers.md
@@ -0,0 +1,141 @@
+# Maintainer guide
+
+This guide covers work on `rtcamp/wp-framework` itself. For consuming the
+library, start with [getting-started.md](getting-started.md).
+
+## Local environment
+
+You need PHP 8.2+, Composer, Node.js, Docker, and a Docker-compatible runtime.
+
+```bash
+composer install
+npm ci
+npm run wp-env start
+```
+
+Composer installs host-side lint and static-analysis tools. `wp-env` provides the
+real WordPress test environment; tests do not mock WordPress functions.
+
+## Run the checks
+
+Run these before opening a pull request:
+
+```bash
+composer lint
+composer analyse
+npm run test:php
+```
+
+`npm run test:php` runs PHPUnit inside the wp-env `tests-cli` container. Its
+`pretest:php` hook installs Composer dependencies in that container first.
+
+`composer test` invokes PHPUnit directly on the host. Use it only when the host
+has a WordPress test suite and database configured through one of the paths
+supported by `tests/bootstrap.php`, such as `WP_TESTS_DIR`. Merely starting
+wp-env does not configure the host command.
+
+Coverage can be collected in wp-env with:
+
+```bash
+npx wp-env start --xdebug=coverage
+npm run test:php:coverage
+```
+
+Stop the environment when it is no longer needed:
+
+```bash
+npm run wp-env stop
+```
+
+## Test conventions
+
+- Follow TDD: add a failing test, then implement the behavior.
+- Mirror `inc/` under `tests/` for new classes and traits.
+- Extend `rtCamp\WPFramework\Tests\TestCase` for code that calls WordPress APIs.
+- A pure-logic test may extend `PHPUnit\Framework\TestCase`.
+- Exercise actual WordPress registrations and registries rather than mocking
+ WordPress functions.
+- Add reusable test-only classes under `tests/Fixtures/`.
+- Keep tests order-independent; PHPUnit runs them in random order.
+
+CI runs PHPCS, PHPStan, and a PHP × WordPress integration-test matrix. The local
+commands above are the closest single-environment equivalent.
+
+## Changing the library
+
+Before editing, identify which surface is affected:
+
+- `inc/Contracts/` contains interfaces, abstracts, and traits consumed by
+ plugins and themes. Signature changes here are breaking.
+- `inc/` contains the container and concrete asset/render loaders.
+- `inc/Utils/` contains reusable services and utilities.
+- `ai/framework-php.instructions.md` is shipped to consumers as their canonical
+ framework and WordPress review guidance.
+- `bin/sync-ai-instructions.js` refreshes and projects those instructions in
+ consuming repositories.
+
+For every behavior change:
+
+1. add or update the matching test;
+2. implement the smallest compatible change;
+3. update the relevant consumer or maintainer documentation;
+4. add an entry under `CHANGELOG.md` → `[Unreleased]`;
+5. run lint, analysis, and integration tests.
+
+For a change under `inc/Contracts/`, also:
+
+- call out the compatibility impact in the pull request;
+- update `ai/framework-php.instructions.md` when consumer guidance or the
+ documented contract changes;
+- check every abstract subclass signature and every documented example affected
+ by the change.
+
+Do not add a package to `composer.json` `require`; production dependencies are
+limited to PHP. Development-only tooling belongs in `require-dev`.
+
+## Adding a class, interface, or trait
+
+- Use PSR-4 paths: `rtCamp\WPFramework\` maps to `inc/`.
+- Add `declare( strict_types = 1 );`.
+- Fully type parameters and return values.
+- Add `@package` and `@since` documentation.
+- Use `snake_case` methods and `PascalCase` classes.
+- Use `static::`, not `self::`, where late static binding is intended.
+- Add a matching test in the mirrored `tests/` path.
+- Add the new API to the appropriate page under `docs/` and to the README/index
+ inventory when it is a new top-level capability.
+
+New `Abstract*` classes should implement `Registrable` and expose abstract
+methods only for the values consumers must supply. Reuse `Loader` and `Container`
+instead of introducing another registration or service-location mechanism.
+
+## Documentation sources
+
+The documentation has two audiences:
+
+- **Implementors:** `README.md` and `docs/{getting-started,architecture,contracts,abstracts,loaders,utilities}.md`.
+- **Maintainers and contributors:** `CONTRIBUTING.md`, this guide, `AGENTS.md`,
+ and `.github/instructions/`.
+
+When implementation changes, search all of these locations for the affected
+class or method. Source docblocks are detailed implementation references, but
+they do not replace the task-oriented examples and behavior notes under `docs/`.
+
+The AI review distribution flow is documented separately in
+[ai-review-system.md](ai-review-system.md). When changing its canonical rules,
+keep the approximately 4,000-character Copilot instruction limit in mind.
+
+## Pull requests
+
+Use the repository pull-request template. A change is ready for review when:
+
+- lint, analysis, and integration tests pass;
+- new or changed behavior has test coverage;
+- compatibility implications are stated;
+- user-facing documentation is current;
+- `CHANGELOG.md` contains an `[Unreleased]` entry;
+- commits follow Conventional Commits.
+
+Branching, release targeting, version selection, and package publication are not
+defined here because the repository does not currently establish one complete,
+authoritative release procedure.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
new file mode 100644
index 0000000..710d7b1
--- /dev/null
+++ b/docs/troubleshooting.md
@@ -0,0 +1,73 @@
+# Troubleshooting
+
+Symptom → cause for the failures the framework produces. Three shapes, and which
+one you get is deliberate:
+
+- **Exceptions** for programming errors that must not be survivable (a missing
+ dependency, an unresolvable service).
+- **`_doing_it_wrong()` notices** for developer mistakes WordPress convention
+ says to report rather than throw on. **These are only visible when `WP_DEBUG`
+ is on** — if a call "does nothing" in production, re-run it with `WP_DEBUG`
+ enabled before assuming the framework is silent.
+- **Silent no-ops** where absence is a legitimate state (a template that isn't
+ there, an unregistered feature flag).
+
+## Registration & loading
+
+| Symptom | Cause | Fix |
+|---|---|---|
+| `RuntimeException: Cannot call get_shared() before load() has been called.` | `get_shared()` called on a loader whose `load()` hasn't run yet — often a service reaching for a shared instance during **construction**, while the loader is still mid-loop. | Retrieve on a hook that fires after the load, not in the constructor. |
+| `RuntimeException: Instance "Acme\Foo" is not registered in the container.` | The class isn't `Shareable`, or it wasn't in the list this loader loaded, or a **later `load()` on the same loader replaced the container**. | Implement `Shareable`; call `get_shared()` on the loader that actually loaded the class (a module's services live on the module, not on `Main`); use one complete class list per loader. |
+| `ArgumentCountError: Too few arguments to function …::__construct()` | A loaded class has a **required** constructor parameter. The loader always calls `new $class_name()`. | Make every parameter optional, or construct the object yourself and pass it in. |
+| A class's hooks never fire | It is `ConditionallyRegistrable` and `can_register()` returned `false` — the instance is still constructed, only `register_hooks()` is skipped. | Check the condition (feature flag, `is_admin()`, `WP_CLI`). |
+| Hook body runs twice | The same behavior is registered from two different classes, or one class is loaded by two different loaders. Duplicates **within one** class list are already de-duplicated. | Load the class from exactly one place. |
+| `LogicException: Acme\Controller::register_routes() must be overridden.` on a REST request | `AbstractRESTController::register_routes()` was not overridden. The base throws instead of being `abstract`, so this surfaces when `rest_api_init` fires, not at class load. | Implement `register_routes()`. |
+| Two singletons return the same object | A class and its subclass both resolved through `get_instance()`; the trait's `static::$instance` is **one storage slot** for both. | Don't call `get_instance()` on a subclass. Give each singleton its own `use Singleton;`, or use `Shareable` + `get_shared()`. See [upgrading.md](upgrading.md#100--101). |
+| `Error: Access to undeclared static property …::$instance` | Running framework 1.0.0 with a constructor that assigns `static::$instance = $this`. | Upgrade to 1.0.1+, where that is the supported pattern. |
+
+## Assets, components & templates
+
+| Symptom | Cause | Fix |
+|---|---|---|
+| `_doing_it_wrong`: *Asset file "app.js" is missing. The asset will not be registered.* | No file at `//app.js`. The asset path is relative to the assets dir and **carries no extension** in the call. | Build first; check the `AssetLoader` constructor's `$base_dir` / `$assets_dir`. |
+| `_doing_it_wrong`: *Asset manifest "…" is invalid; the file modification time will be used as the version.* | `app.asset.php` exists but doesn't return an array. | Regenerate the build. Harmless otherwise — registration continues with a `filemtime()` version. |
+| `_doing_it_wrong`: *Block manifest file is missing. Blocks will not be registered.* | `register_block_manifest()` got a manifest path that doesn't resolve under the base dir. | Pass the path **relative to the base dir**, e.g. `build/blocks-manifest.php`. |
+| Script registers but never loads | Registration is not enqueueing. | Call `wp_enqueue_script()` with the handle. `ComponentLoader` does this for component assets; `AssetLoader` never does. |
+| Handles collide with another package | `HANDLE_PREFIX` left at its `wp-framework-` default. | Override the constant in the consumer's `AssetLoader` subclass. |
+| `RuntimeException: Acme\Components requires an AssetLoader: inject one via the constructor or override get_asset_loader().` | The `ComponentLoader` subclass was constructed with no asset loader — typical when the framework `Loader` instantiates it with no arguments. | Build one in the subclass constructor, or override `get_asset_loader()` to resolve a shared instance lazily. |
+| `_doing_it_wrong`: *Component "Foo" could not be resolved.* | No `Foo/Foo.php` under any layer of the hierarchy, **or the name was rejected**: names must match `^[A-Za-z0-9_-]+$` and be ≤128 characters. A slash or `..` is refused outright — that check is a security boundary, not a convenience. | Fix the path or the name. |
+| A theme override isn't picked up | The render passed `allow_override => false`, or the override sits in a layer the loader doesn't search (a plugin-owned loader searches child → parent → package; a theme's own loader collapses the redundant layers). | Drop the option, or place the file in a searched layer. |
+| Overrides stop resolving after a theme or blog switch | The hierarchy and component metadata are memoised per request, and both depend on the active theme. | Call `clear_cache()` after `switch_theme()` / `switch_to_blog()` on a long-lived (`Shareable`) loader. |
+| `render()` outputs nothing, no notice | A missing **template** is a deliberate silent no-op (`locate()` returns `false`). | Check with `locate( $slug, $name )`. |
+| Wrong template wins | Resolution puts the **name** in the outer loop, so a `{slug}-{name}.php` in the package beats a generic `{slug}.php` in the theme — matching core's `locate_template()` precedence. | Override the specific variant, not the generic one. |
+
+## Utilities
+
+| Symptom | Cause | Fix |
+|---|---|---|
+| `InvalidArgumentException: Encryptor only supports GCM ciphers …` | A non-GCM cipher was passed. The stored `IV ‖ tag ‖ ciphertext` layout is GCM-specific. | Use `aes-256-gcm` (the default) or another `-gcm` cipher. |
+| `RuntimeException: No encryption key provided. …` | Constructed with an empty key and `key()` not overridden. | Pass a key, or override the protected `key()` seam. |
+| `_doing_it_wrong`: *OpenSSL extension is not loaded.* and `encrypt()`/`decrypt()` return `false` | The OpenSSL PHP extension is missing on the host. | Install/enable it. `Encryptor` is the only part of the package that needs it. |
+| `decrypt()` returns `false` | Tampered or truncated ciphertext (GCM authentication failed), non-base64 input, or the wrong key. **`false` is a return value, not an exception** — always check it. | Verify the key domain; treat a failure as untrusted data. |
+| Same value encrypts to a different blob every time | Correct: a fresh random IV per call. | Never compare ciphertexts for equality; decrypt and compare plaintext. |
+| A feature flag is always off | `is_enabled()` is fail-closed and returns `false` for an unregistered flag **silently** — usually a typo, or `register()` running after the check. | Register before checking; use the exact registered slug. |
+| `_doing_it_wrong`: *Feature flag "x" collides with already-registered "y" …* | Two slugs normalize to the same storage key. The first registration is kept. | Rename one. |
+| `_doing_it_wrong`: *Feature flag "x" is not registered; enable() ignored.* | `enable()` / `disable()` called for an unregistered flag. | Register it first. |
+| A toggle on the settings page won't change | The flag is locked by a PHP constant, which always wins over the stored value. The checkbox renders disabled and the stored value is preserved. | Remove the constant from `wp-config.php`. Note `'false'` (the string) is treated as `false`. |
+| SWR still stampedes across workers | `remember_swr()` locking needs a **persistent** object cache (Redis, Memcached). With WordPress's default request-local cache the lock isn't shared between PHP workers. | Deploy a persistent backend; without one the API still works as get-or-set. |
+| A deleted cache key comes back stale | `delete()` removes the primary key only — `{key}_stale` and `{key}_lock` survive. | `flush_group()` to invalidate an SWR entry completely. |
+| Nothing appears in the log | `Logger` writes only when logging is enabled, which tracks `WP_DEBUG` by default. | Enable `WP_DEBUG`, or override the protected `is_enabled()` seam. |
+| Two modules overwrite each other's transients | Unprefixed `set_transient()` calls somewhere. `Transients` exists precisely to namespace them. | Construct one `Transients` per module with its own prefix. |
+| `_doing_it_wrong`: *Timer "x" has already been started / was never started / has already been stopped* | Timer misuse. Reads are silent: `get()` returns `null` for an unknown label. | Share the same `Timer` **instance** across scopes (register it `Shareable`); a new instance has no timers. |
+
+## Environment
+
+| Symptom | Cause | Fix |
+|---|---|---|
+| `Class "rtCamp\WPFramework\…" not found` | The consumer's `vendor/autoload.php` was never required, or the package resolved from a stale `vendor/`. | Require the autoloader in the plugin/theme entry point; `composer update rtcamp/wp-framework`. |
+| Composer can't find the package | It is not on public Packagist. | Add the VCS `repositories` entry — see [getting-started.md](getting-started.md#install). |
+| A missing abstract method only surfaces at runtime | The subclass doesn't implement everything the abstract declares. | Run PHPStan in the consuming package; it catches contract breaks before a request does. |
+
+Still stuck? The classes are small and heavily commented — `Loader::load()` in
+[`inc/Contracts/Traits/Loader.php`](../inc/Contracts/Traits/Loader.php) explains
+the whole boot in one screen.
diff --git a/docs/upgrading.md b/docs/upgrading.md
new file mode 100644
index 0000000..bdfd482
--- /dev/null
+++ b/docs/upgrading.md
@@ -0,0 +1,74 @@
+# Upgrading
+
+What changes between releases of `rtcamp/wp-framework`, and what a consuming
+plugin or theme has to do about it. [`CHANGELOG.md`](../CHANGELOG.md) is the
+complete per-release record; this page carries only the entries that require a
+code change on the consumer side.
+
+## Versioning promise
+
+The package follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
+and the surface that version numbers describe is `inc/Contracts/` — every
+interface, abstract, trait, and public method signature under it.
+
+| Change | Version bump | Consumer impact |
+|---|---|---|
+| Renamed or re-signatured member of `inc/Contracts/` | major | Subclasses must be updated. |
+| Removed public method or class anywhere in `inc/` | major | Callers must be updated. |
+| New abstract, interface, utility, or optional method | minor | None; adopt when useful. |
+| New `abstract` method on an existing abstract | major | Every subclass must implement it. |
+| Behavior fix inside an existing method | patch | Usually none — read the entry. |
+
+Pin with `^1.0` so Composer takes minors and patches and refuses the next major.
+Read this page and the changelog before widening a constraint across a major.
+
+## Upgrade routine
+
+```bash
+composer update rtcamp/wp-framework
+composer lint && composer analyse # in the consuming package
+```
+
+Then run the consumer's own test suite. Static analysis catches the majority of
+contract breaks (a missing abstract implementation, a changed signature) before
+runtime does.
+
+## 1.0.0 → 1.0.1
+
+**Affects:** any class using the `Singleton` trait.
+
+1.0.0 stored singleton instances in a private, class-string-keyed map. 1.0.1
+restored the ecosystem-standard `protected static $instance` storage, written by
+`get_instance()` once the constructor returns.
+
+Two consequences:
+
+- **Early self-assignment works again, and is the supported pattern.** A
+ constructor that does work able to re-enter `get_instance()` — a `Main` that
+ loads classes whose constructors call `Main::get_instance()` — must publish
+ itself first:
+
+ ```php
+ protected function __construct() {
+ static::$instance = $this; // before any work that can re-enter
+ add_action( 'plugins_loaded', [ $this, 'boot' ] );
+ }
+ ```
+
+ On 1.0.0 this fataled with *"Access to undeclared static property"*. If that
+ line was removed as a 1.0.0 workaround, restore it.
+
+- **A class and its subclasses share one storage slot.** This is the trade-off
+ of the trait's single static property, and it is documented on the trait. Do
+ not call `get_instance()` on a subclass of a class that uses `Singleton` —
+ whichever side resolves first occupies the slot for both. Give each singleton
+ its own `use Singleton;`, or prefer `Shareable` + `get_shared()`.
+
+No other 1.0.1 change is consumer-visible. See
+[contracts.md](contracts.md#singleton) for the full trait reference.
+
+## When an upgrade breaks something
+
+Framework failures are mostly loud — see
+[troubleshooting.md](troubleshooting.md) for the symptom → cause table, then the
+changelog entry for the release you moved to.
diff --git a/docs/utilities.md b/docs/utilities.md
index 120a554..2b48b83 100644
--- a/docs/utilities.md
+++ b/docs/utilities.md
@@ -24,6 +24,10 @@ collide.
for sensitive values before they go into the database (API tokens, secrets).
**AES-256-GCM** by default.
+The OpenSSL PHP extension must be loaded when `encrypt()` or `decrypt()` is
+called. If it is unavailable, the method reports incorrect usage and returns
+`false`.
+
GCM is *authenticated*: it produces an auth tag that makes tampering detectable,
so a modified ciphertext fails to decrypt instead of silently returning garbage.
The class hard-rejects any cipher whose name doesn't end in `-gcm` (the stored
@@ -65,6 +69,20 @@ $nav = $cache->remember( 'nav_items', fn() => build_nav(), 'theme', 300 );
the other services, register a `Cache` instance as `Shareable` in a consumer's
container, or extend it to change the backend behaviour.
+The direct wrapper methods are also available:
+
+| Method | Behavior |
+|---|---|
+| `get( $key, $group = '', $force = false, &$found = null )` | Read a value; `$found` distinguishes a miss from a stored falsy value. |
+| `set( $key, $value, $group = '', $expiration = 0 )` | Store a serializable value; `0` means no expiry. |
+| `delete( $key, $group = '' )` | Delete one key. It does not delete SWR companion keys. |
+| `flush_group( $group )` | Flush the namespaced group when the active cache backend supports group flushing; otherwise return `false`. |
+| `remember( $key, $callback, $group = '', $expiration = 0 )` | Return a hit or synchronously generate and store a miss. |
+| `remember_swr( $key, $callback, $group = '', $expiration = 0 )` | Add stale data and locking around an expensive regeneration. |
+
+With context `my-plugin`, group `posts` becomes `my-plugin:posts`, and the empty
+group becomes `my-plugin`. Override `resolve_group()` to change that scheme.
+
For hot keys where a simultaneous miss would stampede the backend, use
`remember_swr( $key, $callback, $group, $expiration )`. On expiry one caller takes
a short lock and regenerates the value in the foreground while every other caller
@@ -79,6 +97,17 @@ It keeps two companion entries — `{key}_stale` (the fallback, stored at roughl
the TTL) and `{key}_lock` — so avoid passing a `$key` that already ends in
`_stale` or `_lock`.
+On a stale hit, all callers—including the caller that wins the lock and performs
+the synchronous regeneration—receive the stale value for that request. The new
+value is stored for subsequent requests. On a cold start with neither fresh nor
+stale data, the lock winner receives the generated value.
+
+Cross-process stampede protection requires a persistent object-cache backend
+such as Redis or Memcached. With WordPress's default request-local cache, the API
+still behaves as a get-or-set helper, but locks are not shared across PHP workers.
+For complete invalidation of an SWR entry, flush its group; deleting the primary
+key leaves its `_stale` and `_lock` companions intact.
+
## Logger
[`inc/Utils/Logger.php`](../inc/Utils/Logger.php) — a PSR-3-*style* logger that writes
@@ -139,9 +168,8 @@ $store->delete( 'user_count' );
[`inc/Utils/FeatureSelector.php`](../inc/Utils/FeatureSelector.php) — a
feature-flag registry with per-context toggle storage. It is **fail-closed**:
-only registered flags resolve, and an unregistered or mistyped slug always
-returns `false` from `is_enabled()`, so a typo can't accidentally run a feature
-that doesn't exist.
+only a key produced by a registered flag can resolve, and an unknown normalized
+key returns `false` from `is_enabled()`.
```php
$features = new FeatureSelector( 'my-plugin' );
@@ -159,6 +187,31 @@ For a registered flag the lookup precedence is:
3. **default `true`** — features ship on. The selector exists to turn things
*off*, not on.
+### Registry and state API
+
+| Method | Behavior |
+|---|---|
+| `register( $features )` | Accept one slug, a list of slugs, or a `slug => metadata` map. First registration wins. |
+| `is_enabled( $flag )` | Resolve a registered flag through constant, stored value, then default. |
+| `enable( $flag )` / `disable( $flag )` | Persist a registered flag's state; return `false` and warn for an unknown flag. |
+| `get_registered()` | Return registered slugs in registration order. |
+| `get_features()` | Return metadata keyed by original slug. |
+| `get_context()` | Return the constructor context without normalization. |
+| `shared_option_key()` | Return the single option holding this context's flags. |
+| `flag_key( $flag )` | Return the normalized, dash-preserving storage key. |
+| `constant_name( $flag )` | Return the PHP constant used to lock the flag. |
+
+For context `my-plugin` and flag `dark-mode`, the defaults are option
+`my_plugin_features`, array key `dark-mode`, and constant
+`MY_PLUGIN_FEATURE_DARK_MODE`. Registration detects collisions after key
+normalization and keeps the first registration. Because lookups use that same
+normalized key, spelling variants that normalize identically refer to the same
+registered flag; callers should nevertheless use the original registered slug.
+
+A defined constant always wins over the stored toggle. The string `'false'` is
+treated as false to handle a common `wp-config.php` mistake; other values use
+normal PHP boolean conversion.
+
## FeatureSelectorSettingsPage
[`inc/Utils/FeatureSelectorSettingsPage.php`](../inc/Utils/FeatureSelectorSettingsPage.php)
@@ -187,6 +240,12 @@ final class MyFeaturesPage extends FeatureSelectorSettingsPage {
It's the ready-made UI for the toggles `FeatureSelector` reads — register it
like any other `Registrable`.
+The page registers a single array option with a sanitization callback. A flag
+locked by a PHP constant renders as disabled, and saving the page preserves its
+previous stored value. `register_fields()` runs only on `admin_init`; the setting
+itself is also registered on `rest_api_init`, where the wp-admin field helpers
+are unavailable. The render method performs its own capability check.
+
## Timer
[`inc/Utils/Timer.php`](../inc/Utils/Timer.php) — named timing segments that persist
@@ -204,6 +263,21 @@ $elapsed = $timer->stop( 'render' ); // float seconds
$all = $timer->get_all(); // every timer, with computed elapsed
```
+`get( $label )` returns `null` for an empty or unknown label. Otherwise it
+returns:
+
+```php
+[
+ 'start' => 0.0, // absolute microtime value
+ 'end' => null, // absolute microtime value, or null while running
+ 'elapsed' => 0.0, // seconds since start
+ 'laps' => [ 'after_query' => 0.0 ], // seconds since start, keyed by lap name
+]
+```
+
+`get_all()` returns that shape keyed by timer label. All running timers in one
+`get_all()` call use the same time snapshot.
+
- **Instance-based, not a singleton.** The start-here / stop-there pattern shares
state by sharing the *instance*: register one as `Shareable` in the consumer's
container (the same pattern as `Cache`) so every hook resolves