Polymer ecosystem — repository & framework design¶
Status: proposal / sketch Audience: Polymer maintainers Last updated: 2026-06-03
This document proposes how to structure the Polymer packages for development and distribution, and defines the DI + configuration + event-bus conventions that let plugins extend one another with loose coupling. It currently lives in the testing harness repo for convenience; its eventual home is the Polymer framework repo.
1. Goals¶
- Polymer core is a CMS-agnostic framework: a DI container, a layered configuration system, a plugin/extension discovery mechanism, and a conventional event bus that lets plugins tap into one another's behavior — to a degree governed by published contracts.
- CMS-base plugins integrate Polymer with a CMS:
polymer-drupal(and laterpolymer-wordpress-bedrock). - Host plugins layer host-specific behavior on top of a CMS-base plugin: Pantheon, Acquia, Amazee, … for Drupal (and eventually for WordPress).
Dependency direction is strictly one way: host → cms-base → core.
2. Repository topology¶
Do not put everything in one monorepo. Group by coupling + release cadence + who contributes. That yields a small, stable core plus one monorepo per CMS family.
| Repo | Kind | Contains | Cadence |
|---|---|---|---|
digitalpolygon/polymer |
single (or small monorepo) | core framework, plugin/event contracts for the framework, test kit | slowest, most stable |
digitalpolygon/polymer-drupal-plugins |
monorepo → splits | Drupal-family packages (below) | faster |
digitalpolygon/polymer-wordpress-plugins |
monorepo → splits (future) | WordPress-family packages | independent |
The Drupal monorepo subtree-splits to the existing consumable repos
(digitalpolygon/polymer-drupal, digitalpolygon/polymer-pantheon-drupal, …), so
Composer/Packagist consumers are unaffected and never see the monorepo.
Why these boundaries¶
- Core stays clean and slow. Folding Drupal + WordPress into core would drag its
dependencies, CI matrix, and release cadence. Core should be a thing you depend on
at
^1and forget. - A monorepo per family is the real win. The painful changes are within a family — e.g. the base plugin emits a new event that every host plugin consumes. Those should be one PR, one integration CI run, which a family monorepo gives you, while subtree-splits keep packages individually installable.
- WordPress is a separate family (different deps, toolchain, contributors). Same logic applies to any partner-contributed host plugin.
- Host plugins rarely change alone; co-locating them with the base avoids the cross-repo version-pin coupling that is brittle (see §6).
3. The non-negotiable enabler¶
Publish core as versioned releases; depend on tags, never branches. Inter-package
constraints like "digitalpolygon/polymer": "dev-feature/some-branch" break the moment
a branch merges or is deleted. Everything outside a monorepo must depend on
^1 / 0.x-dev (a real, resolvable version). Inside a monorepo, packages reference
each other by path automatically, so the problem doesn't arise.
4. Drupal-family monorepo layout¶
polymer-drupal-plugins/ # dev-only monorepo; never required directly
├── composer.json # root dev env: path-requires every package + dev tooling
├── monorepo-builder.php # package discovery + interdependency/version sync
├── phpstan.neon.dist # shared; each package extends it
├── phpcs.xml.dist # shared standard (prevents per-package config drift)
├── .github/workflows/
│ ├── ci.yml # validate-monorepo, cs/sa/lint, integration test
│ └── split.yml # on push to 0.x and on tag: split each package to its repo
├── tests/fixture/ # Drupal site fixture (today's harness) for integration CI
└── packages/
├── drupal-contracts/ # digitalpolygon/polymer-drupal-contracts (events + interfaces)
├── drupal/ # digitalpolygon/polymer-drupal (base plugin)
├── pantheon-drupal/ # digitalpolygon/polymer-pantheon-drupal
├── acquia-drupal/ # digitalpolygon/polymer-acquia-drupal (future)
└── amazee-drupal/ # digitalpolygon/polymer-amazee-drupal (future)
Root composer.json (dev only)¶
{
"name": "digitalpolygon/polymer-drupal-plugins",
"type": "project",
"require": {
"digitalpolygon/polymer": "^1"
},
"require-dev": {
"symplify/monorepo-builder": "^11",
"phpstan/phpstan": "^1.11",
"squizlabs/php_codesniffer": "^3",
"drupal/core-recommended": "^10.3"
},
"replace": {
"digitalpolygon/polymer-drupal-contracts": "self.version",
"digitalpolygon/polymer-drupal": "self.version",
"digitalpolygon/polymer-pantheon-drupal": "self.version"
},
"repositories": [
{ "type": "path", "url": "packages/*" }
],
"minimum-stability": "dev",
"prefer-stable": true
}
replace + path repositories make the packages resolve to each other locally with
no version pins — exactly the coupling that is brittle across separate repos.
5. Split configuration¶
monorepo-builder.php — keeps inter-package constraints and shared composer keys
in sync (tool: symplify/monorepo-builder):
<?php
use Symplify\MonorepoBuilder\Config\MBConfig;
return static function (MBConfig $mbConfig): void {
$mbConfig->packageDirectories([__DIR__ . '/packages']);
$mbConfig->dataToAppend([
'require-dev' => [
'phpstan/phpstan' => '^1.11',
'squizlabs/php_codesniffer' => '^3',
],
'license' => 'GPL-2.0-only',
'minimum-stability' => 'dev',
'prefer-stable' => true,
]);
};
monorepo-builder validate— fail CI if package versions / inter-deps drift.monorepo-builder bump-interdependency "^1.1"— bump cross-package constraints at release.
.github/workflows/split.yml — the git-subtree split (package → read-only repo):
name: Split packages
on:
push:
branches: [ "0.x" ]
tags: [ "*" ]
jobs:
split:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- package: drupal-contracts
repo: polymer-drupal-contracts
- package: drupal
repo: polymer-drupal
- package: pantheon-drupal
repo: polymer-pantheon-drupal
steps:
- uses: actions/checkout@v4
- uses: symplify/monorepo-split-github-action@v2.3.0
with:
package_directory: "packages/${{ matrix.package }}"
repository_organization: digitalpolygon
repository_name: ${{ matrix.repo }}
branch: "0.x"
env:
GITHUB_TOKEN: ${{ secrets.SPLIT_TOKEN }}
Tags on the monorepo propagate to each split repo; consumers keep doing
composer require digitalpolygon/polymer-pantheon-drupal:^1.
ci.yml runs once for the whole family: monorepo-builder validate → cs/sa/lint
across all packages → an integration job that assembles tests/fixture as a
Drupal site and runs real polymer commands. That integration job is the safety net
that catches install-time and cross-plugin breakage before merge.
6. Event-bus & contracts design¶
Principles¶
- Build on PSR-14 (
Psr\EventDispatcher) / the Symfony EventDispatcher Polymer already uses. - Plugins couple to contracts (event classes + a name catalog), never to one another's implementations.
- Two event shapes:
collect— listeners contribute to a collection (e.g. add settings files).alter— listeners mutate a resolved result. (Core already does this withCollectConfigContextsEvent/AlterConfigContextsEvent.)- A contracts package is a normal PSR-4 library — not a Polymer extension, so it
declares ordinary
autoload.psr-4(it has none of the runtime-forced-namespace behavior that enabled extensions have).
Package graph (loose coupling)¶
polymer (core: dispatcher + framework events)
▲ ▲
polymer-drupal-contracts ─────────────────┘ (events/interfaces only)
▲ ▲
polymer-drupal (emits) polymer-pantheon-drupal (listens)
polymer-pantheon-drupal depends on polymer-drupal-contracts, not on
polymer-drupal — so it reacts to base events without coupling to base internals.
Event catalog¶
namespace DigitalPolygon\Polymer\Drupal\Contracts\Event;
final class DrupalSettingsEvents
{
/** Listeners contribute settings files. Payload: CollectSettingsFilesEvent. */
public const COLLECT_SETTINGS_FILES = 'polymer_drupal.settings.collect_files';
/** Listeners may reorder/remove the resolved set. Payload: AlterSettingsFilesEvent. */
public const ALTER_SETTINGS_FILES = 'polymer_drupal.settings.alter_files';
}
Event object (stable, typed payload)¶
namespace DigitalPolygon\Polymer\Drupal\Contracts\Event;
use Symfony\Contracts\EventDispatcher\Event;
final class CollectSettingsFilesEvent extends Event
{
/** @var list<string> */
private array $files = [];
public function __construct(
public readonly string $site,
public readonly string $environment,
) {}
public function addSettingsFile(string $absolutePath): void
{
$this->files[] = $absolutePath;
}
/** @return list<string> */
public function getSettingsFiles(): array
{
return $this->files;
}
}
Emitter (in polymer-drupal)¶
$event = new CollectSettingsFilesEvent($site, $environment);
$this->dispatcher->dispatch($event, DrupalSettingsEvents::COLLECT_SETTINGS_FILES);
foreach ($event->getSettingsFiles() as $file) { /* include */ }
Listener (in polymer-pantheon-drupal, depends only on contracts)¶
namespace DigitalPolygon\Polymer\polymer_pantheon_drupal\EventSubscriber;
use DigitalPolygon\Polymer\Drupal\Contracts\Event\CollectSettingsFilesEvent;
use DigitalPolygon\Polymer\Drupal\Contracts\Event\DrupalSettingsEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
final class PantheonSettingsSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [DrupalSettingsEvents::COLLECT_SETTINGS_FILES => ['onCollect', 0]];
}
public function onCollect(CollectSettingsFilesEvent $event): void
{
if ($event->environment === 'pantheon') {
$event->addSettingsFile(__DIR__ . '/../../settings/pantheon.settings.php');
}
}
}
Subscribers are registered by each plugin's existing service provider against the container's dispatcher — the DI wiring Polymer already performs during discovery.
Conventions¶
- Contracts versioning:
polymer-drupal-contractsis the strictest-semver package. Implementations change freely; bump its major only when an event payload breaks. Plugins requirepolymer-drupal-contracts: ^1. - "Fires only if the emitter is active." Contracts let a plugin listen with no hard code dependency, but the event only fires if the emitting plugin is installed and enabled. Keep two concerns separate: contracts = what I react to; composer/enable dependency = whether the emitter exists.
- Documented event catalog. Maintain a human-readable list of events, payloads, and stability guarantees. An "everything listens to everything" bus is hard to debug; namespaced names + stable payloads + a catalog keep it tractable.
7. Host axis — current plan (deferred extraction)¶
We will not create standalone host-base packages or a hosting monorepo yet.
Host-specific behavior is developed inside the CMS combo package (e.g.
polymer-pantheon-drupal in the Drupal monorepo) until a second CMS (WordPress)
creates a real need to share it — the "extract on second use" rule. (Host-base
plugins like Pantheon and Amazee don't co-evolve with each other, so they never
warrant a shared "hosting monorepo"; when extracted, each becomes its own
standalone repo, like core.)
Internal seam — do this from day one¶
Even though it's one package, structure it so the agnostic host code can lift out cleanly later:
polymer-pantheon-drupal/src/
├── Hosting/ ← agnostic Pantheon services / value objects / exceptions (Terminus,
│ Pantheon API, environment detection). The future
│ digitalpolygon/polymer-pantheon. NO Drupal references.
├── Drupal/ ← glue: listens to polymer-drupal events, calls into Hosting/.
└── Plugin/ ← discovered commands / hooks / templates. Discovery roots at the
│ FIXED Plugin\Commands|Hooks|Template namespaces and recurses, so
│ the seam NESTS under them (not the reverse):
├── Hooks/Hosting/ agnostic Pantheon hooks
└── Template/
├── Hosting/ agnostic Pantheon config + CI (pantheon.yml, …)
└── Drupal/ Drupal-coupled (Drush site yaml)
Crucially, discovery roots at Plugin/Commands (etc.) and recurses below it, so
src/Plugin/Commands/Hosting/Foo.php is found but a top-level
src/Hosting/Plugin/Commands/Foo.php is not — nest the seam under Plugin/,
never wrap Plugin/ inside Hosting/. (Confirmed empirically; no core change.)
Rules that keep the seam clean:
- One-way dependency: nothing under Hosting/ may import Drupal. The urge to
use Drupal\... there is the signal the code belongs in Drupal/ instead. This
single rule is what keeps the future polymer-pantheon truly agnostic.
- Interact via events/contracts now: the Drupal/ glue listens to
polymer-drupal's events (§6) and consumes a small internal hosting interface,
rather than reaching into hosting internals — so the seam exists before the split.
- Namespace for a clean lift: keep agnostic code under a Hosting\ sub-namespace
so extraction is a move + find-replace to DigitalPolygon\Polymer\Pantheon\.
Agnostic services live in a top-level Hosting/; agnostic discovered classes
(commands/hooks/templates) nest under the fixed discovery roots, e.g.
…polymer_pantheon_drupal\Plugin\Template\Hosting\PantheonYaml. The CI seam guard
enforces the one-way rule across every Hosting/ location.
Extraction — when WordPress begins¶
Trigger: starting a polymer-pantheon-wordpress plugin. Then:
1. Lift src/Hosting/ into a new standalone repo digitalpolygon/polymer-pantheon
(released at ^1, like core) — not the Drupal monorepo, not a hosting
monorepo.
2. Slim polymer-pantheon-drupal to glue depending on polymer-pantheon: ^1 +
polymer-drupal.
3. Add polymer-pantheon-wordpress (in the WordPress monorepo) depending on
polymer-pantheon: ^1 + polymer-wordpress-bedrock.
Trajectory¶
- Now:
polymer-pantheon-drupal=Hosting/(agnostic, quarantined) +Drupal/(glue), entirely within the Drupal monorepo. No standalone host packages, no hosting monorepo. - WordPress starts:
Hosting/→digitalpolygon/polymer-pantheon(own repo); the combo in each CMS monorepo becomes thin glue depending on it at^1.
8. The testing harness¶
Today's standalone harness (drupal/recommended-project consuming the packages via
path repos) becomes the family monorepo's integration fixture under
tests/fixture/, exercised by ci.yml. WordPress gets an analogous fixture. The
separate, manually-driven harness repo is no longer needed.
9. Migration plan (phased)¶
- Phase 1 — establish the framework (API still churning). [decided] Stand up
the Drupal monorepo and vendor core in as
packages/core/(split todigitalpolygon/polymer) so core + Drupal plugins iterate atomically while the API churns. The Drupal monorepo is core's source of truth during this phase. Add thedrupal-contractspackage and migrate one cross-plugin behavior (e.g. settings-file collection) to the event bus as a proof. - Phase 2 — stabilize & extract core to its own repo. Trigger: when the core
API stabilizes or when the WordPress family begins, whichever comes first
(a WordPress family cannot depend on core while core lives inside the Drupal
repo). Move core's history to
digitalpolygon/polymer, make it core's source of truth, cut^1releases, and repoint the Drupal monorepo frompackages/core/(path) todigitalpolygon/polymer: ^1. Plan this cutover deliberately (one-time history move + split-target flip). - Phase 3 — grow families. Add host combos (e.g.
amazee-drupal) as packages in the Drupal monorepo, developed with the internalHosting/+Drupal/seam (§7). Extract an agnostic host-base to its own standalone repo only when a second CMS needs it (§7). Start the WordPress monorepo with the same shape once core is standalone.
End-state¶
Three independent repos, both families depending on core at ^1:
digitalpolygon/polymer (core framework; may itself be a small
monorepo splitting polymer + polymer-contracts
+ a test kit if core exposes shared interfaces)
▲ ▲
polymer-drupal-plugins (monorepo) polymer-wordpress-plugins (monorepo)
Framework-level contracts ship with core (shared by all families); CMS-specific contracts live in their family monorepo.
10. Prior art / references¶
- Symfony develops in the
symfony/symfonymonorepo (components undersrc/Symfony/Component/*) and read-only-splits to individual packages (symfony/console, etc.). Its Contracts packages (symfony/service-contracts,symfony/event-dispatcher-contracts, namespaceSymfony\Contracts\*, undersrc/Symfony/Contracts/) are the canonical "depend on contracts, not implementations" pattern — theSymfony\Contracts\EventDispatcher\Eventbase class used above comes from there. Symfony's splitting usessplitsh/lite, not monorepo-builder. - Laravel develops in the
laravel/frameworkmonorepo and splits to theilluminate/*packages. symplify/monorepo-builder(Symplify; used by Rector, EasyCodingStandard) is the tooling sketched here for package discovery, version sync, and releases. The subtree split itself is done bysymplify/monorepo-split-github-action(wrappingsplitsh/lite).