Skip to content

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 later polymer-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 ^1 and 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 with CollectConfigContextsEvent / AlterConfigContextsEvent.)
  • A contracts package is a normal PSR-4 librarynot 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-contracts is the strictest-semver package. Implementations change freely; bump its major only when an event payload breaks. Plugins require polymer-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)

  1. Phase 1 — establish the framework (API still churning). [decided] Stand up the Drupal monorepo and vendor core in as packages/core/ (split to digitalpolygon/polymer) so core + Drupal plugins iterate atomically while the API churns. The Drupal monorepo is core's source of truth during this phase. Add the drupal-contracts package and migrate one cross-plugin behavior (e.g. settings-file collection) to the event bus as a proof.
  2. 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 ^1 releases, and repoint the Drupal monorepo from packages/core/ (path) to digitalpolygon/polymer: ^1. Plan this cutover deliberately (one-time history move + split-target flip).
  3. Phase 3 — grow families. Add host combos (e.g. amazee-drupal) as packages in the Drupal monorepo, developed with the internal Hosting/ + 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/symfony monorepo (components under src/Symfony/Component/*) and read-only-splits to individual packages (symfony/console, etc.). Its Contracts packages (symfony/service-contracts, symfony/event-dispatcher-contracts, namespace Symfony\Contracts\*, under src/Symfony/Contracts/) are the canonical "depend on contracts, not implementations" pattern — the Symfony\Contracts\EventDispatcher\Event base class used above comes from there. Symfony's splitting uses splitsh/lite, not monorepo-builder.
  • Laravel develops in the laravel/framework monorepo and splits to the illuminate/* 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 by symplify/monorepo-split-github-action (wrapping splitsh/lite).