Skip to content
Open
Show file tree
Hide file tree
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ configuration doc.

- [Installing][installing] — Composer, Strauss, and the constants Strauss must leave alone.
- [Configuration][configuration] — the hook prefix, the container, every sub-plugin key.
- [Recipes][recipes] — a settings toggle, a manifest of add-ons, and staging the absorption across
releases.
- [Conflict handling][conflicts] — the policies, when they run, and the guard's limits.
- [Filters][filters] — the runtime overrides for policies and notice text.
- [Notices][notices] — where the queue lives, who may see it, and how to render it yourself.
Expand All @@ -69,6 +71,7 @@ source.

[installing]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/installing.md
[configuration]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/configuration.md
[recipes]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/recipes.md
[conflicts]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/conflict-handling.md
[filters]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/filters.md
[notices]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/notices.md
Expand Down
3 changes: 2 additions & 1 deletion docs/conflict-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@ yet at the moment the guard is read, and the bundled copy would load on top of i

**Version negotiation.** The library never compares versions, so it will not spare a standalone that
is newer than the bundled copy. Express that yourself: check the version and return
`Conflict_Policy::DEFER` from the `conflict_policy` [filter](filters.md), which has the final say.
`Conflict_Policy::DEFER` from the `conflict_policy` [filter](filters.md), which has the final say —
[the recipe](recipes.md#defer-to-a-newer-standalone) is ten lines.

**Renamed standalone directories.** `standalone_plugin_basename` is the path as installed. A site
that renamed the standalone's directory is not detected, and there is no fallback that derives the
Expand Down
202 changes: 202 additions & 0 deletions docs/recipes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
# Recipes

The shapes hosts actually write. [Configuration](configuration.md) is the key-by-key reference
behind them.

## Toggle a sub-plugin from a setting

Register unconditionally and put the condition in `enabled`. It is re-read on every request rather
than resolved at registration, so the settings screen saves an option and does nothing else:

```php
Absorber::register( [
'slug' => 'give-recurring',
'bundled_plugin_file' => GIVE_PLUGIN_DIR . 'sub-plugins/give-recurring/give-recurring.php',
'plugin_loaded_constant' => 'GIVE_RECURRING_VERSION',
'standalone_plugin_basename' => 'give-recurring/give-recurring.php',
'enabled' => static fn() => (bool) get_option( 'give_recurring_enabled', true ),
] );
```

`enabled` is the first of five gates, and the only one ahead of the guard constant:

```mermaid
flowchart TD
A["enabled"] -->|false| S1["skipped, silently"]
A -->|true| B["plugin_loaded_constant already defined?"]
B -->|yes| S2["skipped: a copy is already running"]
B -->|no| C["dependency_check"]
C -->|false| S3["skipped, dependency notice queued"]
C -->|true| D["bundled file is readable?"]
D -->|no| S4["skipped, reported with _doing_it_wrong"]
D -->|yes| E["should_load filter"]
E -->|false| S5["skipped, silently"]
E -->|true| F["require_once"]
F --> G["activation_callback, once ever"]
```

Three things follow from where it sits.

**Switching the toggle off unloads nothing.** The `require_once` on the current request already
happened; the next request is the one that skips it. Anything that has to stop immediately — a
feature, an endpoint — is the sub-plugin's own business to gate.

**A disabled sub-plugin is invisible to conflict resolution as well.** `Conflict\Detector` asks
`is_enabled()` before it asks anything else, so an off toggle also stops the standalone being
deactivated. That is the intent: off means this library leaves the plugin alone, standalone
included, and a site running the standalone keeps running it.

**Keep the callable cheap.** It is called on the conflict pass and again on the load pass, so at
least twice on an admin page view. An option read is fine; a remote licence check belongs behind a
cached value.

## Register several add-ons from one manifest

One array, one loop — which is also how you control the order, since sub-plugins load in
registration order and anything extended at include time has to be registered before its extender:

```php
$sub_plugins = [
[
'slug' => 'give-recurring',
'plugin_loaded_constant' => 'GIVE_RECURRING_VERSION',
'standalone_plugin_basename' => 'give-recurring/give-recurring.php',
],
[
'slug' => 'give-stripe',
'plugin_loaded_constant' => 'GIVE_STRIPE_VERSION',
'standalone_plugin_basename' => 'give-stripe/give-stripe.php',
],
];

foreach ( $sub_plugins as $sub_plugin ) {
Absorber::register(
$sub_plugin + [
'bundled_plugin_file' => sprintf(
'%1$ssub-plugins/%2$s/%2$s.php',
GIVE_PLUGIN_DIR,
$sub_plugin['slug']
),
'enabled' => static fn( Sub_Plugin $sub ) => give_addon_is_enabled( $sub->get_slug() ),
]
);
}
```

An entry the library cannot use throws `Config_Exception` out of the `Absorber::register()` call it
is in, so a typo names itself in a stack trace pointing at your loop rather than surfacing later
from inside a core hook. A duplicate `slug` is the one that does surface later — the registrations
are buffered and collide at the first read, on `plugins_loaded`.

## Choose a policy, and know what the site owner sees

The policy is only reached for a sub-plugin that is enabled, names a `standalone_plugin_basename`,
and whose standalone is active right now:

```mermaid
flowchart TD
A["plugins_loaded 5: is the standalone active?"] -->|no| Z["nothing to resolve"]
A -->|yes| P{"conflict_policy"}
P -->|DEACTIVATE| D1["deactivate silently, queue the merge notice, redirect"]
P -->|NOTICE_ONLY| N1["queue a notice, leave the standalone running"]
P -->|DEFER| F1["do nothing at all"]
D1 --> D2["next request: the bundled copy loads"]
N1 --> G["this request: the bundled copy stands down on the guard constant"]
F1 --> G
```

| Policy | The standalone | This request | Afterwards |
|---|---|---|---|
| `DEACTIVATE` | turned off, silently, network-wide | its code is still in memory, so the bundled copy stands down; the user is redirected back to the screen they asked for | the bundled copy loads, and a merge notice explains the swap |
| `NOTICE_ONLY` | left running | the bundled copy stands down | unchanged until someone acts on the notice |
| `DEFER` | left running | the bundled copy stands down | unchanged, and nothing is said |

The redirect under `DEACTIVATE` is why the deactivation is not silent to the user: it re-renders the
screen with the standalone's code gone. It happens once per request no matter how many standalones
were turned off, and only on an interactive admin `GET` that carries no `action` —
[conflict handling](conflict-handling.md#when-resolution-runs) has the full gate list.

## Ship the absorption over several releases

Bundling the code and taking over from the standalone do not have to be the same release. Moving
the `conflict_policy` one step per release lets a site be warned before anything of theirs is turned
off:

```mermaid
flowchart LR
R1["<b>Release 1</b><br>bundle it, DEFER<br>standalone sites unchanged,<br>everyone else gets the bundled copy"] --> R2
R2["<b>Release 2</b><br>NOTICE_ONLY<br>standalone sites are asked<br>to deactivate it"] --> R3
R3["<b>Release 3</b><br>DEACTIVATE<br>the remainder are merged,<br>and told so"]
```

Release 1 is the safe one to leave in place for a while: the bundled copy ships dormant on every
site that has the standalone, which is exactly the population you are least sure about, and the
guard constant is doing the work rather than any decision of yours. Release 3 is the only one that
touches a site's active plugins.

Nothing here needs a code change per release beyond the constant — or none at all, if the policy
comes from a callable reading a value you can move without shipping:

```php
'conflict_policy' => static fn() => get_option( 'give_absorption_stage', Conflict_Policy::DEFER ),
```

An unrecognised value is treated as `NOTICE_ONLY`, never as consent to deactivate, so a stale or
misspelt option cannot turn a plugin off.

## Defer to a newer standalone

The library never compares versions — deliberately, since "newer" is a question only the host can
answer. Express it as a `conflict_policy` filter, which runs last and has the final say. The
standalone's code is already loaded by the time this is asked, so its own version constant is there
to read:

```php
add_filter( 'give/plugin_absorber/conflict_policy', static function ( $policy, $sub_plugin ) {
if ( $sub_plugin->get_slug() !== 'give-recurring' ) {
return $policy;
}

$standalone = defined( 'GIVE_RECURRING_VERSION' ) ? GIVE_RECURRING_VERSION : '0';

// Let a standalone that is ahead of the bundled copy keep the site.
return version_compare( $standalone, GIVE_RECURRING_BUNDLED_VERSION, '>' )
? Conflict_Policy::DEFER
: $policy;
}, 10, 2 );
```

`DEFER` leaves the standalone active, and the guard constant then stands the bundled copy down on
its own. That is the whole mechanism: no version is stored anywhere, and the site converges the
moment the bundled copy catches up.

## Do per-site work on multisite

Deactivation is network-wide, the notice queue is a network option, and so is the activation
record — so `activation_callback` runs **once for the network**, in whichever site's request
reached the load pass first. Per-site work loops:

```php
'activation_callback' => static function ( Sub_Plugin $sub_plugin ) {
if ( ! is_multisite() ) {
\Give\Recurring\Install::create_tables();

return;
}

foreach ( get_sites( [ 'fields' => 'ids', 'number' => 0 ] ) as $site_id ) {
switch_to_blog( $site_id );
\Give\Recurring\Install::create_tables();
restore_current_blog();
}
},
```

That is the right shape for a handful of sites and the wrong one for a large network, where the
loop runs inside `plugins_loaded` on one unlucky request. Bind `Activator_Interface` instead and
record "once, ever" per site — a per-site option, or your own migration table — so each site pays
only for itself.

Write the callback to be idempotent either way. "Once, ever" is bookkeeping rather than a lock: the
record is written after the callback returns, so a failure is retried, and two first requests
arriving together can both run it.
Loading