Skip to content

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

Accepted.

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.

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.

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.

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

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.

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.

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.

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.

Closed in 2026-04-25-subscriber-manager-improvements: 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.

2026-04-25-service-provider-consolidation