# 2026-04-25 — Service-provider & manager consolidation

## Status

Accepted.

## Context

A code audit flagged a handful of repeated patterns across our service providers and admin schema resolvers — most of them variations on "fetch tagged extenders from the container" or "track a list of subscribers." Some were genuine duplication worth removing; others were structural similarities that didn't actually share behaviour. This ADR records which ones we consolidated, which ones we left alone, and why — so future audits don't try to re-litigate the same call.

## What we consolidated

### 1. `ResolvesTaggedExtenders` trait

Ten resolver classes in `packages/admin/src/Support/Schemas/` each had a private `getExtenders()` method whose entire body was `return app()->tagged(SOME_TAG);`. Same shape, different tag.

We extracted the lookup into `Capell\Admin\Support\Schemas\Concerns\ResolvesTaggedExtenders`, which exposes a single abstract `extenderTag(): string` method. Each resolver names its tag and inherits the lookup. The duplication was small per file but consistent across ten files; pulling it out makes the tag the explicit contract instead of burying it inside boilerplate.

`ResourceHeaderActionExtenderResolver` is the only resolver that filters extenders after retrieval (by `$pageClass`). Its filter logic stayed in the public method while the trait owns the tagged lookup — same pattern, just with a post-filter step.

**For future contributors:** when adding a new tagged-extender resolver, use the trait. Don't reintroduce the inline `app()->tagged(...)` call.

### 2. Generic `SubscriberManager`

`Capell\Core\Support\Subscriber\SubscriberManager` previously stored subscribers under numeric keys, which made `subscribe()` non-idempotent and `unsubscribe()` fragile (it had to scan the array). The same shape was duplicated in the sibling repo's `PublishingStudioManager`.

The rewrite moves storage to class-string-keyed arrays (`subscribe()` is now idempotent, `unsubscribe()` is `unset()`), adds `getSubscribers()` and `hasSubscriber()` accessors, and documents the type via a `@template TContract` PHPDoc generic. The generic is doc-only — PHPStan templating, no runtime change.

`Capell\PublishingStudio\Support\PublishingStudioManager` (sibling repo) is now a five-line subclass: `extends SubscriberManager<WorkspaceEventSubscriber>` plus the existing `Macroable` trait. All the subscribe/unsubscribe/notify logic lives in core.

## What we deliberately did NOT consolidate

The following candidates were investigated and rejected as premature abstractions. **Future audits should not re-raise them without new evidence.**

### Registry storage unification

`BlockRegistry`, `RenderHookRegistry`, `SettingsSchemaRegistry`, and `AdminEventRegistry` all happen to use plain arrays for storage, but their public surfaces don't line up: target-keyed components vs. priority-sorted hooks vs. group-scoped settings vs. event maps. A shared `BaseRegistry` would have to satisfy a generic contract that fits none of them well, and each registry would still need most of its methods overridden. The shape similarity is coincidence, not duplication.

### `MediaFieldFactory` unification

This pattern is already cleanly composed. `AdminSpatieMediaFieldFactory` decorates `SpatieMediaFieldFactory` via constructor injection, both implement the `MediaFieldFactory` interface, and the admin binding (`AdminServiceProvider::registeringPackage`) lets plugins swap the implementation entirely. The "third variant" the original audit referenced (`CuratorMediaFieldFactory`) does not exist in either repo.

### `ConsoleServiceProvider` boot trait

Only two console providers exist (`publishing-studio`, `blog`), both under thirty lines, and the "shared" scaffold reduces to a single `if ($this->app->runningInConsole()) { $this->commands([...]); }` line. A trait would hide more than it saves.

### "Five `AdminServiceProvider`s" claim

The original audit reported five copies. There is one (`packages/admin/src/Providers/AdminServiceProvider.php`). The audit conflated this with each package's own top-level service provider, which are unrelated.

## Follow-up

Closed in [2026-04-25-subscriber-manager-improvements](../../superpowers/plans/2026-04-25-subscriber-manager-improvements.md): subscribers now resolve via the container, the optional `validate()` is expressed as a `ValidatingSubscriber` sub-interface, and `subscribe()` enforces the configured contract at runtime via an overridable `subscriberContract()` hook.

## Related plan

[2026-04-25-service-provider-consolidation](../../superpowers/plans/2026-04-25-service-provider-consolidation.md)