Modules & ports describes how the platform's own modules are shaped. This page is the packaging contract: what a module that arrives from outside the repository has to be, what it may hook into, and — stated as plainly as the rest — what it cannot do yet.
Read the last section before you start building. Several seams named here are published at one end only, and it is cheaper to know that now than to discover it after writing the class.
A module is a family of packages; the bundle is its Symfony adapter#
There is no CoolMS-specific packaging format, no plugin archive and no registry. A module is a family of Composer packages named by its subject, and the bundle is one member of it: the adapter the Symfony kernel loads, not the module itself. coolms/entity is the module; coolms/entity-bundle is how Symfony reaches it. Putting the domain inside the bundle is the one arrangement the platform's own rules forbid, so do not start there.
The shape, as the published families enforce it: a suffix names a superstructure over the subject, and the namespace segment equals the suffix.
| package | namespace | holds |
|---|---|---|
coolms/entity |
CoolMS\Entity\ |
the domain: entities, value objects, ports. type: library. Imports no Doctrine\ORM\ and no Doctrine\DBAL\ (doctrine/collections, a data structure, is permitted). |
coolms/entity-application |
CoolMS\Entity\Application\ |
use cases over the domain. Requires the virtual package coolms/entity-persistence-implementation and names no adapter. |
coolms/entity-doctrine |
CoolMS\Entity\Doctrine\ |
the persistence adapter: mapping and repositories. Provides coolms/entity-persistence-implementation. |
coolms/entity-bundle |
CoolMS\Entity\Bundle\ |
the Symfony adapter: the bundle class, the DI extension, compiler passes. type: symfony-bundle. |
Not every family has four packages. field and taxonomy have no application tier and ship three — coolms/field, coolms/field-doctrine, coolms/field-bundle — and a fourth would be empty. Three is not a shortcut; four is not the template. Ship the tiers you have.
Persistence mapping lives in the adapter, never in the domain package. In the two three-package families it is external XML (field-doctrine/src/mapping/Definition.orm.xml, taxonomy-doctrine/src/mapping/TaxonomyNode.orm.xml); entity-doctrine maps through drivers of its own (TraitMappingDriver, ExtrasFieldMappingDriver). Either way, coolms/entity, coolms/field and coolms/taxonomy carry no mapping and no ORM import — a static rule, not a habit.
The persistence seam, and why a bundle does not install on its own#
The bundle names no persistence adapter. That is deliberate: storage stays swappable, and the installing application selects it. Today the seam is expressed one package below the bundle: coolms/entity-application requires a virtual package, coolms/entity-persistence-implementation, and coolms/entity-doctrine provides it. Nothing in the entity family requires entity-doctrine by name. The application that installs the family does.
The consequence is that composer require coolms/entity-bundle on its own does not resolve, and neither does coolms/core-bundle. Composer says so, and it names the cure:
coolms/entity-application[...] require coolms/entity-persistence-implementation ^2.0
-> could not be found in any version, but the following packages provide it:
- coolms/entity-doctrine Doctrine ORM/DBAL adapters for coolms/entity ...
Consider requiring one of these to satisfy the coolms/entity-persistence-implementation requirement.
That is the seam working, not a packaging defect. Two things about it are easy to miss:
- Each family's seam closes separately.
entity-bundleplusentity-doctrinestill fails — oncoolms/core-persistence-implementation— because entity's graph reachescore-application, and providing entity's adapter says nothing about core's. An application that installs entity requirescoolms/entity-doctrineandcoolms/core-doctrine. - The three-package families close it for you. With no application tier to carry a virtual name,
field-bundleandtaxonomy-bundlerequire their-doctrinepackage outright, andfield-doctrineandtaxonomy-doctrinerequirecore-doctrine. Sotaxonomy-bundleresolves alone, andfield-bundlefails alone only through the entity family it depends on.
Measured cold from the registry, in an empty project, on 2026-09-14: core-bundle alone fails; entity-bundle alone fails; field-bundle alone fails (through entity); taxonomy-bundle alone resolves. core-bundle + core-doctrine resolves; entity-bundle + entity-doctrine fails on core; field-bundle + entity-doctrine resolves.
The bundle package#
The bundle is a Composer package of type: symfony-bundle whose class extends AbstractCoolmsBundle, and it requires its own family's domain package the way it requires anything else. For a family acme/crm with a domain in Acme\Crm\:
{
"name": "acme/crm-bundle",
"type": "symfony-bundle",
"require": {
"php": "^8.5",
"coolms/core": "^2.0",
"coolms/core-bundle": "^2.0",
"acme/crm": "^1.0",
"symfony/config": "^8.0",
"symfony/dependency-injection": "^8.0",
"symfony/http-kernel": "^8.0"
},
"autoload": {
"psr-4": { "Acme\\Crm\\Bundle\\": "src/" }
}
}
acme/crm itself is a plain type: library with Acme\Crm\ and no framework in its require. If it needs persistence, that is acme/crm-doctrine with Acme\Crm\Doctrine\, and whether you declare it through a virtual name (as entity does) or require it from the bundle (as field does) is yours to choose; the platform does both.
coolms/core is the compatibility marker. It is the package a module declares to say which generation of the platform it works with, and it is the only one that carries that meaning. Requiring coolms/coolms instead is a mistake — that is the application, a type: project, and depending on it means depending on a deployment rather than a contract.
At the time of writing the platform is on
^2.0:coolms/core, theentity,fieldandtaxonomyfamilies andtheme-adminare^2.0;coolms/rqlis still^1.0. Take the constraint from the manifest, not from a version number quoted in a discussion.
The bundle class#
final class AcmeCrmBundle extends AbstractCoolmsBundle
{
public const string COMPONENT_NAME = 'acme-crm';
public static function getRequiredBundles(): array
{
return [EntityBundle::class];
}
}
COMPONENT_NAME is the kebab-case identifier used for DI alias keys. getRequiredBundles() declares hard dependencies, and it is not documentation: boot() compares the list against kernel.bundles and throws a LogicException naming both classes when one is missing. A module that needs a peer fails at boot with a readable message rather than at the first call with a missing-service error.
One caveat follows from that being a list of class names: you can only declare a dependency on a bundle whose class your package can reference. Today that means the published ones — CoreBundle, EntityBundle, FieldBundle and TaxonomyBundle. The platform's other bundle classes, VFSBundle among them, live in the application, so a module that depends on VFS cannot currently say so in getRequiredBundles(). It is the same published-at-one-end problem described under the seams, applied to bundle classes rather than interfaces.
Installation#
A module that needs one-time setup implements one or both published installer contracts. Both are collected by tag and run by bin/console coolms:install, in this order:
| step | contract | what it is for |
|---|---|---|
| 1 | CoolMS\Core\Install\StructureInstallerInterface |
create your directories, so seeded content has somewhere to land |
| 2 | CoolMS\Core\Install\ModuleInstallerInterface::install() |
seed your own data |
| 3 | CoolMS\Core\Install\ModuleInstallerInterface::postInstall() |
work that depends on other installers having finished |
Within each phase the order is derived, not registration order. An installer that needs something another installer creates implements CoolMS\Core\Install\DeclaresPrerequisitesInterface and names what it requires and what it provides; InstallOrder::sort() orders the phase from those declarations and refuses to run — coolms:install exits non-zero with the unsatisfied requirement named — when a requirement has no provider or the declarations form a cycle. An installer that declares nothing is placed after the ones that do. postInstall() remains for work that must follow every module's install(), such as setting ownership on a node a peer created.
Both interfaces require idempotence. coolms:install is expected to be run repeatedly — on deploy, after adding a module, during recovery — and an installer that duplicates its own seed on the second run is a defect, not an inconvenience.
Tables#
A module owns its own schema, and this is the part the platform does not yet let you ship. No package contributes migrations today. The tables of the three extracted families are created by the application's migration directory — five of its fourteen migrations name coolms_taxonomy_* or coolms_field_* tables — and Doctrine runs migrations in version order, not in dependency order. A third-party module's tables are therefore installed by the integrator: a migration written into their application, or doctrine:schema:update against your -doctrine package's mapping. Measured on 2026-09-14; the intended shape is migrations shipped per package and ordered by the Composer graph, and neither half exists.
Two constraints hold regardless, and the second is easy to miss:
Reference other modules by UUID, without a foreign key. A constraint pointing into a table you do not own outlives your module: it remains after the module stops loading, and the owner of that table cannot then alter its own schema. Store the identifier, and treat "the referenced thing is gone" as a domain condition your code answers.
Declare the package whose tables you depend on. If your module's schema genuinely depends on another package's tables, it must require that package, so that whoever writes the installing migration knows the order. The platform's release tooling already computes that graph in tiers for its own packages; a module that expects a table without requiring the package that creates it has declared nothing and will fail on a clean install.
Settings#
Module settings are contributed, not read from files at runtime:
final class CrmSettingsContributor implements ModuleSettingsContributorInterface
{
// CoolMS\Core\Settings\ModuleSettingsContributorInterface
}
The contributor declares a ModuleSettingsDefinition whose defaults are the authoritative key set — a key absent from them does not exist, and one reader resolves all of them. Settings are edited by administrators in the admin UI; they are not a configuration file your module parses itself.
Supply them lazily unless they are literals. Reading a single settings block forces every contributor to declare itself, so an eagerly computed default is paid for by every reader of every other module's block. Pass a callable as $lazyDefaults — it is deferred until your block is actually read, and called at most once. You cannot supply both: a non-empty $defaults makes $lazyDefaults ignored, so a contributor cannot half-declare each way.
The admin surface#
A module does not ship admin screens as code. There is no JavaScript plugin surface, no way to register an Angular component from a PHP package, and none is planned. A module's presence in the admin UI is declared, and assembled from generic screens the platform already renders:
- navigation entries via
CoolMS\Core\Navi\NaviGraphContributorInterface(coolms.navigraph.contributor) - lists via a datagrid definition
- editing via a form definition
- arbitrary structured content via a dynamic entity
- configuration via the settings contributor above
This is a deliberate trade. It costs you a bespoke interface and buys the platform the ability to restyle, theme, translate and permission every module's screens uniformly — and it means your module does not break when the admin front end is rebuilt.
What that reaches today, measured against the admin's routes. A navigation entry lands where you point it. A dynamic entity gets a generic list and edit screen at /admin/dynamic-records/<alias>. Settings get the settings screen. A datagrid or form definition for a table of your own has no generic screen to land on — the admin has no route that renders an arbitrary datagrid definition — so a module that ships its own aggregate is headless in the official admin unless it models that aggregate as a dynamic entity. That is decision #2832 (2026-09-09), not an omission: modules extract headless, installing coolms/<module> gives you the backend and the API, and the trigger for revisiting is the first third-party module that needs an admin surface. If that is you, say so; that is how the question returns.
The seams#
Extension points are tagged services: implement the interface, and autoconfiguration places you in the right registry with no DI wiring. Around a hundred such tags exist. What matters for a module arriving from outside is where the interface lives, because a contract in the application's src/ cannot be named by an external package at all.
Published — an external module can implement these today#
| seam | contract |
|---|---|
| Install (data) | CoolMS\Core\Install\ModuleInstallerInterface |
| Install (structure) | CoolMS\Core\Install\StructureInstallerInterface |
| Settings | CoolMS\Core\Settings\ModuleSettingsContributorInterface |
| Admin navigation | CoolMS\Core\Navi\NaviGraphContributorInterface |
| Option sources | CoolMS\Core\Option\OptionSourceProviderInterface |
| Backup participation | CoolMS\Core\Backup\BackupContributorInterface |
| Retention / pruning | CoolMS\Core\Retention\RetentionPrunerInterface |
| Outbound channels | CoolMS\Core\Channel\OutboundChannelInterface |
| File-format loaders | CoolMS\Core\Config\FileFormatLoaderInterface |
| API manifest | CoolMS\Core\Application\ApiManifest\ApiManifestContributorInterface (in coolms/core-application, not coolms/core) |
| Field widgets | CoolMS\Core\Field\FieldWidgetProviderInterface |
| Field configuration | CoolMS\Core\Field\FieldConfigProviderInterface |
| Datagrid configuration | CoolMS\Core\DataGrid\DataGridConfigProviderInterface |
| Form configuration | CoolMS\Core\Form\FormConfigLoaderInterface |
| Theme providers | CoolMS\Core\Theme\ThemeProviderInterface |
| System user | CoolMS\Core\Identity\SystemUserProviderInterface |
| VFS installation | CoolMS\Core\Install\VfsInstallerInterface |
| Install prerequisites (ordering) | CoolMS\Core\Install\DeclaresPrerequisitesInterface |
| VFS path claims | CoolMS\Core\Install\DeclaresVfsPathsInterface |
| Removal | CoolMS\Core\Install\ModuleUninstallerInterface |
Not published — the tag exists, the interface does not ship#
| seam | where the contract lives today |
|---|---|
| Workflow service tasks | src/Workflow/Domain/Engine/ServiceTaskHandlerInterface.php |
| Workflow elements | src/Workflow/Domain/Engine/ElementStrategyInterface.php |
| Document formats | src/Document/Domain/Provider/DocumentFormatProviderInterface.php |
These are not refusals; they are seams that were built for internal callers and never finished the journey outward. A seam published at one end only is treated as a defect, and contracts move to coolms/core when an external module needs them.
That mechanism works. When this page was first written the second table held ten entries, including SystemUserProviderInterface and VfsInstallerInterface — named here as first in the queue because a module acting on its own behalf needs an identity to act as, and one that stores files needs its directories created the way every other module's are. Both have since moved, along with the field, datagrid, form and theme contracts. Three remain.
If the seam you need is in the second table, say so. That is the mechanism by which it moves.
Declaring what you touch#
A module declares the resources it expects to own on its installer, and what each declaration buys differs, so here it is per declaration:
- Prerequisites (
DeclaresPrerequisitesInterface) are enforced: they order the install, and an unprovided requirement or a cycle stopscoolms:installbefore anything runs. - VFS paths (
DeclaresVfsPathsInterface) are checked and reported:coolms:installcomputes the collisions across every installer and prints a warning naming each path and its claimants — then proceeds. A collision is visible at install time; it is not refused. - Settings namespace and tables are advisory: nothing reads them yet.
They are written down now so that enforcement, where it arrives, checks something modules already declare rather than something they must be retrofitted with.
Removal#
Removal is part of the contract, and it has two distinct meanings that must not be conflated.
Remove — the module stops loading. Nothing is deleted. Its navigation entries unseed, so the admin UI stops offering screens that no longer work; everything else stays inert in place, and re-installing the module restores a working system with its data intact. Content that references a removed module degrades visibly — a missing widget shows as missing — rather than silently rendering as though nothing was ever there.
Purge — an explicit, declared, destructive operation that deletes the module's data. It is never the default, never implied by removal, and never runs without being asked for by name.
Remove is built; purge is not. bin/console coolms:module:remove <module> stops a module loading and undoes what its installation created — navigation entries unseed, configuration goes back — and its own docblock says the rest: remove is not purge; nothing here deletes data. coolms:module:restore is the inverse. The contract a module implements to take part is CoolMS\Core\Install\ModuleUninstallerInterface, published in coolms/core (and, as of 2026-09-14, implemented by none of the platform's own modules yet, so the command works from what installers declared). There is no purge command, and no interface for one.
Configuration files#
Several subsystems are driven by YAML under config/modules/<module>/<type>/, and a package can now ship some of it. CoreBundle publishes every registered bundle's config/ that carries module YAML, and the generic config loader — layouts, dialogs, navigation graphs, datagrids — reads the application's directory first and then every bundle's, so an installation overrides what a package ships and never the other way round.
Two loaders still read the application's config/ only: the form registry (form definitions) and dynamic-entity definitions. Measured 2026-09-14. Until those two follow, a form or dynamic entity definition is contributed in PHP or installed into the application's config/ by hand; a datagrid, a layout, a dialog or a navigation graph ships in the package.
What this adds up to#
An honest summary of what you can build today, without a single change to the platform:
- a family whose bundle declares its dependencies and fails loudly when they are absent — yes
- one-time installation of data and directory structure — yes
- its own tables, referenced by UUID — yes
- administrator-editable settings — yes
- admin navigation, lists, forms and structured content, declared — yes
- participation in backup, retention, outbound channels and the API manifest — yes
- a field widget, a datagrid provider, a form configuration, a theme, a system-user identity and VFS installation — yes, all published since this page was first written
- a workflow service task, a workflow element strategy, a document format — not from an external package, until those three contracts are published
- shipping YAML definitions — partly: datagrids, layouts, dialogs and navigation graphs yes; forms and dynamic-entity definitions not yet
- shipping migrations for its own tables — no; the integrator installs the schema
- a list and edit screen for its own aggregate in the official admin — no, by decision (#2832), unless the aggregate is a dynamic entity
- being removed cleanly — yes, without deleting data (
coolms:module:remove); being purged — not yet
Stated limits#
Collected in one place, because each of them will otherwise be discovered the expensive way:
- No module authored outside this repository exists yet. Three of the platform's own families —
entity,field,taxonomy— have been extracted, published, and are consumed from the registry by thecoolms/coolmsskeleton, whose documented install path completes on an empty database (measured 2026-09-11). So the packaging has been exercised end to end; a module written by someone else has not. You would be the first of those, and the first of anything finds problems the design did not anticipate. - No purge exists. Remove is built and deletes nothing; purge is specified above and not built.
- No admin screens for your own aggregate, by decision (#2832): declared navigation, dynamic entities and settings reach the admin; a datagrid or form over your own table has nowhere to render.
- No migrations from a package. Your schema is installed by the integrator.
- YAML contribution is partial. Datagrids, layouts, dialogs and navigation graphs ship in the package; form and dynamic-entity definitions are still application-local.
- Three seams are still internal, listed above, and move outward on request. There were ten when this page was written; seven have moved.
- Declarations are enforced unevenly. Prerequisites order the install and refuse it when unsatisfied; VFS path collisions are reported at install and not refused; settings and table declarations are read by nothing.
AbstractCoolmsBundleis down togetRequiredBundles(),boot()andCOMPONENT_NAME.getOptionalBundles()and theVENDORconstant were deprecated when this page was written and have since been removed. Anything built on them is already broken.- There is no module registry, marketplace or discovery mechanism. Installation is
composer requireplus a line in the application's bundle list.
None of this is a reason not to build a module. It is the difference between building one with the map in hand and building one twice.