Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 151 additions & 5 deletions docs/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,144 @@ $container->singleton( Registrar_Interface::class, My_Registrar::class );
a per-site option on a large multisite network where one run for the whole network is not what you
want. See [the recipe](recipes.md#do-per-site-work-on-multisite).

## What each seam must implement

The table names the job; these are the methods, and every one of them is required — PHP refuses to
load a class that leaves one out, at class-declaration time rather than when the library first calls
it. Two of this library's types appear below:

```php
use Nexcess\PluginAbsorber\Exceptions\Config_Exception;
use Nexcess\PluginAbsorber\Sub_Plugin;
```

`Sub_Plugin` is one registered configuration, and its accessors — `get_slug()`,
`get_standalone_plugin_basename()`, the message accessors named below — are how an implementation
reads the config keys and runs [their filters](filters.md). A throw from any of these is caught at
the hook boundary and reported through `_doing_it_wrong()`, so it costs that one step rather than the
site; nothing retries it on the same request.

### `Registry\Contracts\Registrar_Interface`

```php
public function register( Sub_Plugin $sub_plugin ): void;
public function all(): array;
```

`register()` stores one sub-plugin, and a slug may only be registered once — throw a
`Config_Exception` on the second rather than overwriting, or a copy-pasted slug silently replaces the
sub-plugin it collided with. `all()` returns every registered sub-plugin keyed by slug, in
registration order: `array<string,Sub_Plugin>`. The library narrows that array to `Sub_Plugin`
instances before reading it, so anything else in it is dropped rather than fataling — which loses a
sub-plugin quietly, and is worth not doing.

### `Notices\Contracts\Writer_Interface`

```php
public function queue_merge_notice( Sub_Plugin $sub_plugin ): void;
public function queue_conflict_notice( Sub_Plugin $sub_plugin ): void;
public function queue_stranding_notice( Sub_Plugin $sub_plugin ): void;
public function queue_dependency_notice( Sub_Plugin $sub_plugin ): void;
public function option_name(): string;
```

- `queue_merge_notice()` — a deactivation has just been performed. Raised exactly once and never
re-queued, and the resolver redirects immediately afterwards, so it has to be kept somewhere that
outlives the request that wrote it. Word it from
`$sub_plugin->get_conflict_notice_message( $your_default )`, which applies the config key and its
filter over the default you pass.
- `queue_conflict_notice()` — `NOTICE_ONLY`: the standalone is still running and the user is being
asked to turn it off. Same accessor, a default that asks rather than reports.
- `queue_stranding_notice()` — multisite only, and recurring rather than once-ever: a network-active
standalone was left active because deactivating it network-wide would strand the sites the host
plugin never reaches. Its wording is `$sub_plugin->get_stranding_notice_message()`, and it must not
tell the user to deactivate the standalone — see
[the stranding guard](conflict-handling.md#the-multisite-stranding-guard).
- `queue_dependency_notice()` — `dependency_check` returned false, so the sub-plugin did not load at
all. `$sub_plugin->get_dependency_notice_message()`.
- `option_name()` — where *your* implementation keeps the queue. It is on the contract rather than on
the default class because `Absorber::notices()->option_name()` is what a host
[rendering the queue itself](notices.md#rendering-them-yourself) reads: name an option nothing
writes to and that host reads an empty one.

### `Plugin\Contracts\Deactivator_Interface`

```php
public function deactivate( string $basename ): void;
```

One method, one argument: a plugin basename like `give-recurring/give-recurring.php`, the only
identifier WordPress itself accepts. Called unattended during `plugins_loaded`, under the
`DEACTIVATE` policy, on behalf of a user who did not ask for it. It reports nothing — whether the
standalone actually went away is asked of the checker afterwards, not of you — so a no-op
implementation is the honest way to say "plugin state is managed outside WordPress here", and an
implementation that means to deactivate but does not leaves two copies of the plugin to load.

### `Plugin\Contracts\Checker_Interface`

```php
public function is_active( string $basename ): bool;
public function is_network_active( string $basename ): bool;
```

Two methods, asked for different reasons. `is_active()` answers "this plugin's code is going to run
this request", so both scopes count: a network activation runs it as surely as a site one.
`is_network_active()` is network scope only, and it is the one question the
[stranding guard](conflict-handling.md#the-multisite-stranding-guard) asks — "would deactivating this
reach every site". Return `false` whenever the site is not multisite, exactly as core's
`is_plugin_active_for_network()` does: callers lean on that instead of guarding with `is_multisite()`
themselves, so answering `true` off a network can have the guard decline a deactivation on a site
with nothing to strand.

```php
use Nexcess\PluginAbsorber\Plugin\Contracts\Checker_Interface;

class My_Checker implements Checker_Interface {
public function is_active( string $basename ): bool {
// Whatever your plugin considers the real list, before your own filters touch it.
return in_array( $basename, my_plugin_stored_active_plugins(), true )
|| $this->is_network_active( $basename );
}

public function is_network_active( string $basename ): bool {
if ( ! is_multisite() ) {
return false;
}

$network_active = get_site_option( 'active_sitewide_plugins', [] );

return is_array( $network_active ) && array_key_exists( $basename, $network_active );
}
}
```

### `Conflict\Contracts\Resolver_Interface`

```php
public function resolve_all(): void;
```

Called once per request, at `plugins_loaded` priority 5, and only after the
[request and capability gates](conflict-handling.md#when-resolution-runs) pass and a conflict has
been found. It is handed nothing: read the registry from the container your
implementation was built with, and resolve every sub-plugin whose standalone is active — including
the ones behind the first, which is why the default catches per sub-plugin. Ending the request is
allowed, and the default does (`wp_safe_redirect()` then `exit`) — but after the loop, never inside
it, or a site with two active standalones never reaches the second.

### `Contracts\Activator_Interface`

```php
public function maybe_run( Sub_Plugin $sub_plugin ): void;
```

Called by the load pass after a `require_once` that actually happened, on every request, for every
sub-plugin that loaded. Deciding it has already run for this slug is the whole job:
`$sub_plugin->get_activation_callback()` hands back the configured `callable` or `null`, it is
invoked with the `Sub_Plugin`, and where "already run" is recorded is yours. Record it after the
callback returns rather than before, so a callback that throws is retried on the next request instead
of being marked done half-finished.

## Class-name bindings must come after boot

Everything without an interface is bound by class name — `Notices\Store`, `Notices\Renderer`,
Expand All @@ -40,6 +178,11 @@ Everything without an interface is bound by class name — `Notices\Store`, `Not
`Absorber::boot()`: di52 reports `has()` true for any class that exists, bound or not, so boot cannot
tell your binding from the container's own willingness to build the class, and replaces it.

Boot also binds `StellarWP\ContainerContract\ContainerInterface` to the container itself, first and
before anything else, so a container that builds unbound classes reflectively can still satisfy the
library classes that take one. That id is an interface rather than a class, so the skip above applies
to it: a container that already answers for it keeps whatever it has, whenever you bound it.

## What rebinding does not buy you

Binding your own `Conflict\Contracts\Resolver_Interface` does not put you in charge of *when*
Expand Down Expand Up @@ -76,11 +219,14 @@ remove_action( 'all_admin_notices', [ Absorber::class, 'render_notices' ] );

## When a binding is wrong

`Absorber::registrar()`, `notices()` and `resolver()` check what your container hands back and throw
a `Config_Exception` naming the interface and the class that failed it, rather than letting a
`TypeError` blame this library for your typo inside `plugins_loaded`. Whatever your container raises
for a binding it cannot build at all comes through unwrapped. `Absorber::all()` drops anything a
rebound registrar returns that is not a `Sub_Plugin`.
`Absorber::registrar()`, `notices()`, `resolver()` and `all()` check what your container hands back
and throw a `Config_Exception` naming the id they asked for and the class that failed it, rather than
letting a `TypeError` blame this library for your typo inside `plugins_loaded`. A binding your
container cannot build at all is reported the same way and not raised at you raw: whatever it threw
is caught and wrapped in a `Config_Exception` that names the id, keeping the original as
`getPrevious()`. `Absorber::boot()` is the one that does not wrap — a container that cannot build the
provider or the scheduler throws its own exception out of your `boot()` call. Past the check,
`Absorber::all()` also drops anything a rebound registrar returns that is not a `Sub_Plugin`.

Nothing is built at boot beyond the two objects that do the booting: each hook resolves its
collaborator when it fires, so a request that reaches none of them builds none of them, and you may
Expand Down
Loading