Status: Draft
Updated: 2026-06-05 Owner: Core
Purpose: Draft for installing, discovering, configuring, and safely extending the CMS through packages with module and provider scopes.
- Defines how packages with
module,captcha-provider, oreditor-providerscopes add or replace functionality. - Covers package manifests, services, routes, assets, migrations, permissions, and lifecycle hooks.
- Depends on the core architecture, event hooks, and theme engine drafts.
- Defines the contract between the CMS core and optional first-party or third-party packages.
Plugin modules are no longer a separate package type. A package with module scope should extend the CMS without forcing the core to anticipate every future use case. A package may contribute routes, services, templates, assets, field types, editor actions, API resources, permissions, migrations, event subscribers, message handlers, or replaceable providers, but only through documented extension points and only while the whole package is active.
Replaceable provider packages are a central package use case. Captcha providers are the first concrete example: the core exposes a resolver and configuration option, while packages such as IconCaptcha contribute the actual provider service, assets, templates, translations, and validation behavior. If a provider package is disabled, the resolver must fall back according to core configuration rather than leaving workflows broken.
The first package system should be intentionally conservative. Discovery, metadata validation, compatibility checks, activation state, and dependency ordering should be implemented before packages can make deep runtime changes. Packages should be easy to inspect from their manifest, service definitions, migrations, tests, and class map references.
The package lifecycle should prefer Symfony-native behavior. A package should integrate through service registration, tagged services, event subscribers, message handlers, route imports, Twig paths, AssetMapper assets, Doctrine migrations, and security voters where applicable. Custom loading logic should stay narrow and focused on discovery, validation, and lifecycle orchestration.
Package dependency resolution should be implemented in the package installer and lifecycle workflow, not in the shared package planner. The resolver should later define how manifests express hard requirements, conflicts, optional suggestions, provided capabilities, compatible core versions, compatible package versions, and update constraints. Dependency checks should be able to compare installed packages, disabled packages, available staged packages, and administrator-selected packages before an activation, update, or uninstall action runs.
Packages must also define how they are removed. Because packages may provide migrations and package-owned tables, uninstall or remove workflows must avoid data leftovers where possible and must ask for explicit confirmation before deleting package-owned data.
Module lifecycle changes may add or remove Twig templates, Tailwind class usage, JavaScript entrypoints, imported CSS, or static assets. Enabling, disabling, installing, updating, or uninstalling a module with frontend contributions should trigger a controlled asset rebuild instead of relying on a filesystem watcher. The rebuild should run through the same operational action-log pattern as other admin lifecycle operations.
Discovered packages should be registered as available but inactive by default. Discovery may parse manifests, validate compatibility, and show diagnostics, but packages must not contribute services, routes, templates, assets, migrations, permissions, providers, or event subscribers until an administrator explicitly activates them. This keeps activation auditable and guarantees that rebuild and lifecycle workflows are not skipped.
Discovery should not run as request-time background work. An automatic discovery trigger may happen after a fresh container boot or cache rebuild, then later only through explicit lifecycle triggers such as an Admin UI refresh action, an installer completion, or a scheduler-driven update check. Because package validation may run linters and syntax checks, normal triggers should queue discovery through Messenger instead of doing all work in the request or cache-warmer process. A product-level discovery run includes manifest scanning, package validation, and registry reconciliation, even when those responsibilities stay split across focused services such as PackageDiscovery, PackageValidator, and PackageRegistryHandler.
Package activation should fail safely. If validation, migration checks, contribution registration, cache invalidation, Tailwind build, or AssetMapper compilation fails, the system should restore the previous active/inactive state where practical and show the failure through the operational action log. This is simpler than a staged compiler model and matches the expectation that lifecycle operations are explicit and recoverable.
- Use
.manifest-style metadata withPACKAGE_keys. - Use the package layout
packages/<package-slug>/with.manifest, optionalpackage.php,src/,config/,templates/,assets/,languages/, andmigrations/where needed. - Suggested required keys:
PACKAGE_AUTHORPACKAGE_SLUGPACKAGE_NAMEPACKAGE_VERSIONPACKAGE_SCOPEPACKAGE_DEPENDENCIES
- Suggested optional keys:
PACKAGE_DESCRIPTIONPACKAGE_NAMESPACEPACKAGE_IMAGEPACKAGE_LICENSEPACKAGE_HOMEPAGEPACKAGE_SOURCEPACKAGE_CHANNEL
PACKAGE_SCOPEvalues are DotEnv-style lists, for example[module, captcha-provider].- Current allowed scopes are
module,captcha-provider, andeditor-providerfor this draft;frontend-theme,backend-theme, andsystem-templateare covered by the theme draft. - A package is activated or deactivated as one unit. If a package combines
modulewith a theme or provider scope, switching that single-active scope deactivates the whole package. - Validate manifests before registering package contributions.
- Resolve module dependencies, conflicts, provided capabilities, optional suggestions, and compatibility constraints before enablement, update, or uninstall workflows mutate state.
PACKAGE_DEPENDENCIESlists Studio package dependencies as package slug plus minimum package version pairs, for example[['hallo-welt', '1.0.1'], ['system', '0.1.0']].- Store active/inactive package state in application configuration or database-backed package state once persistence exists.
- Discover packages from
packages/or a configured package registry. - Do not rediscover packages during normal frontend or backend requests.
- Run discovery only behind explicit triggers such as Admin UI refresh, installer completion, setup, or scheduler/update workflows.
- Defer normal discovery execution through Symfony Messenger because validation can include linter and syntax-check work.
- Treat a product-level discovery run as manifest scan plus package validation plus registry reconciliation. The underlying services may remain separate so scanning, validation, and database synchronization stay independently testable.
- Expose explicit discovery triggers through
App\Core\Package\PackageDiscoveryDispatcher, which is callable by Admin UI, installer, scheduler, updater, recovery tools, and thepackages:discovercommand. - Keep package discovery out of Symfony cache warmers so cold container builds do not require Messenger or database-backed package state.
- Mark newly discovered packages as inactive/available by default.
- Reconcile discovered filesystem packages into
extension_package: new packages are registered inactive, missing packages are markedremoved, valid version/path/manifest mismatches update metadata, and validation failures are markedfaulty. - Let the installer stage or extract package files into their dedicated package folders, then trigger discovery. Validation belongs to the discovery/registry workflow, not to archive extraction itself.
- Keep package update execution deferred: a future updater should compare registered versions and manifest source metadata, then use a narrow Git-backed update stub for packages or the system.
- Require explicit admin activation before a discovered package contributes services, routes, templates, assets, migrations, permissions, providers, event subscribers, or message handlers.
- Keep first-party and third-party packages under the same lifecycle rules, but allow stricter trust defaults for third-party packages.
- Use tagged services for additive contributions.
- Use documented contracts/resolvers for replacement contributions.
- Support replaceable provider packages, for example captcha providers, through documented provider contracts, tagged services, and explicit resolver selection.
- Resolve provider availability through package lifecycle state, manifests, tagged services, and resolver contracts instead of a generic provider-candidate collection event.
- Keep provider-package assets, templates, translations, and configuration namespaced to the package.
- Package translation sources must live under
languages/<locale>/*.yaml. Packages that ship translations must include a configured fallback catalogue; package-owned keys must stay belowpkg.<package-slug>.*, and only active package language files are aggregated into the generated runtimemessagescatalogue. - Provide deterministic fallback behavior when a selected provider is disabled, missing, or uninstalled.
- Use EventDispatcher and Messenger only for documented public package hooks.
- Treat only events listed in
App\Core\Event\PublicEventHookRegistryas stable package extension contracts. Other Symfony events may exist for internal decoupling but are not package API. - Let package subscribers target event class names, for example
App\View\ViewContextEvent, instead of custom string aliases. - Core-owned public dispatch points use
App\Core\Event\PublicEventDispatcherso unregistered hooks and listener failures become structured operation issues instead of raw exceptions. - Core hook failures emit
App\Core\Event\PublicHookFailedEventas an internal diagnostic signal. Package lifecycle may subscribe later to mark, count, or fault the offending package when ownership can be identified. - Mark the offending active package
faultywhen a package-owned listener or handler fails in a way the workflow cannot safely ignore and package ownership can be identified. The fault decision belongs to lifecycle code, not to the dispatcher itself. - Let packages declare migration paths or namespaces, then execute them through a core-owned migration workflow.
- Package-owned database tables should use collision-resistant names such as
pkg_<slug>_<table>. - Deactivation must not drop data. Uninstall may offer data removal through a package purge routine.
- Let packages declare route resources only after compatibility and active state are validated.
- Register package routes during cache warmup or an explicit rebuild flow. Avoid magical runtime route changes.
- Keep package assets namespaced.
- Let packages declare frontend contributions that require asset rebuilds, including templates, Tailwind/CSS sources, JavaScript sources, importmap entries, and static assets.
- Package assets must be self-contained; packages should vendor external dependencies instead of relying on global importmap dependency injection.
- Mirror only active package assets into the AssetMapper-visible package directory and aggregate them through generated CSS/JS registries. Inactive package assets under
packages/must not be exposed by AssetMapper. - Aggregate only active package language files into generated
translations/runtime/{APP_ENV}/messages.{locale}.yamlbefore cache clearing. Inactive package translations underpackages/must not be loaded by Symfony. Cache warmup also refreshes generated runtime catalogues when the translation source hash or generated catalogue hash drifts. - Feed active package templates into Tailwind with generated
@sourcedirectives and feed active package CSS into Tailwind with generated@importdirectives so package classes and styles become part of the single built CSS aggregate. - Mirror static package assets such as images, fonts, videos, SVGs, and vendored browser dependency files without adding them to CSS/JS registries directly. They become public only through references from active templates, CSS, or JavaScript.
- Trigger
assets:rebuildafter package install, activation, deactivation, update, uninstall, or runtime state exits such as active packages becomingfaultyorremoved. The rebuild also refreshes generated translation catalogues from active package sources. Run asset-map compilation only in production. - Run asset rebuilds as ActionLog-backed operations with persisted step entries and progress metadata.
cache:clearshould be the finalizer, not the first step, so live log polling can recover from any brief cache invalidation. - Roll back to the previous package active/inactive state when activation or deactivation fails before it can be safely finalized.
- Activation and deactivation run through
PackageActivator: faulty or removed packages cannot be activated; activating a real filesystem package with a single-active scope deactivates conflicting active filesystem packages; asset rebuild failures roll affected package statuses back. - Activation planning runs before mutation. The first pass resolves package dependencies, rejects malformed declarations and circular hard dependencies, verifies installed versions/status, and reports which packages would be activated or deactivated. The second pass executes the validated plan.
- Virtual/native package records such as the
systempackage remain active fallback records and are not disabled as single-active conflicts for real packages. - Route all future service, route, migration, template, and provider loaders through the active-package gate so they only see active real filesystem packages and may optionally filter by scope.
- Runtime failures from public hook listeners may mark an identified active package
faulty, record failure metadata, and deactivate active reverse dependents with explicit lifecycle messages. If ownership cannot be identified, the failure remains a structured hook issue and no package state is changed. - Use explicit rebuild actions for admin/package lifecycle changes. File watchers may support local development but must not be required for production correctness.
- Keep package asset rebuilds deferred through Messenger in the normal path; if the rebuild dispatch itself fails after registry sync changed active package state, run one synchronous fallback rebuild and surface the dispatch failure in messages/context.
- Provide a remove/uninstall routine for packages that deletes the package directory and marks the registry row as
removed. A separate purge cleanup is available only after removal; it deletes package-owned data through confirmed migrations or cleanup steps and then removes the registry row. Until real package-owned migrations exist, purge goes through a cleanup boundary that is intentionally a no-op. - Load optional package PHP through
package.phponly for active, valid packages. The loader may require package-owned PHP belowsrc/, and hard loader or runtime-provider failures should be recorded through lifecycle diagnostics, mark the packagefaulty, deactivate active reverse dependents, and avoid keeping partial runtime contributions from the failed load. - Keep package permissions declared and mapped through the security draft before routes or admin UI rely on them.
- Provide diagnostics for invalid manifests, missing dependencies, conflicts, disabled packages, and failed contributions.
- Test manifest parsing and validation.
- Test package discovery with valid, invalid, disabled, missing-dependency, and conflicting packages.
- Test discovery trigger policy so normal requests do not scan packages, while explicit lifecycle triggers queue scan, validation, and registry reconciliation.
- Test dependency resolution against installed packages, disabled packages, staged packages, conflicts, suggestions, and provided capabilities once the package installer defines the manifest keys.
- Test newly discovered packages remain inactive until explicitly activated.
- Test inactive packages do not contribute services, routes, templates, assets, migrations, permissions, providers, subscribers, or handlers.
- Test additive tagged service contributions.
- Test replacement provider selection and fallback behavior.
- Test provider packages can contribute namespaced assets, templates, translations, and services without leaking into core package structure.
- Test selected provider fallback when a provider package is disabled or unavailable.
- Test route, asset, migration, and permission declarations separately.
- Test package lifecycle changes trigger asset rebuilds when frontend contributions changed.
- Test failed package activation or deactivation restores the previous state and reports the failure.
- Test active-package gates so loaders ignore inactive, removed, faulty, virtual, or missing package records.
- Test runtime failures fault only identified active packages.
- Test that disabled packages do not contribute routes, services, templates, or migrations.
- Test package remove/uninstall flows with and without package-owned data deletion.
- Run
lint:containerafter service integration changes. - Add integration fixtures for one minimal first-party package and one intentionally invalid package.
- Decision recorded: Treat modules and providers as scopes on one Symfony-integrated package model, not as separate package systems.
- Decision recorded: Package code is not treated as a full sandbox. Activation validation must classify package capabilities through an adjustable allow/warn/block policy registry covering PHP functions, namespaces/classes, templates, routes, hooks, headers, and similar surfaces. Blocked rules prevent activation; warnings remain visible to administrators.
- Decision recorded: Package authors should have one primary
PackageContributions-style builder/DTO API for common contributions. Provider interfaces and tagged services may remain advanced or internal adapter surfaces. - Decision recorded: Require manifest validation before any contribution is registered.
- Decision recorded: Keep the
.manifestKEY=VALUEformat and strengthen validation instead of turning manifests into broad capability dumps. Complex behavior should be validated through package files and contribution registration, not encoded as opaque manifest capability claims. - Decision recorded: Package lifecycle transitions should use a central transition policy/state machine for activation, deactivation, faulting, removal, purge, and future updates.
- Decision recorded: Package dependency maps and compatibility checks belong to the package installer/lifecycle workflow. The shared Core package planner remains dependency-neutral.
- Decision recorded: Use one lifecycle for first-party and third-party packages, with stricter trust and review rules for third-party packages.
- Decision recorded: Packages live under
packages/<package-slug>/and may contain.manifest, optionalpackage.php,src/,config/,templates/,assets/,languages/, andmigrations/. - Decision recorded: Package translations use the package slug as Symfony domain and are loaded only after activation through the package rebuild mirror.
- Decision recorded: Newly discovered packages are inactive by default and require explicit admin activation before contributing anything to the runtime.
- Decision recorded: Active/inactive package state should live in database-backed package state once persistence exists, with configuration used only for initial defaults.
- Decision recorded: Registry package identifiers use the explicit manifest
PACKAGE_SLUG, which also defines the target folder for ZIP installs underpackages/{package-slug}. Human-readablePACKAGE_NAMEstays manifest metadata and must not replace the stable registry identifier. - Decision recorded: Discovery and registry sync do not activate packages. They only reconcile available, removed, updated, or faulty registry state; activation remains an explicit lifecycle operation.
- Decision recorded: Discovery does not run during normal requests. Automatic discovery triggers are limited to fresh container boot or cache rebuild flows; later runs are explicit Admin UI refresh, installer completion, scheduler, or update-check triggers.
- Decision recorded: A product-level discovery run includes manifest scanning,
PackageValidatorvalidation, and registry reconciliation, even if the implementation keepsPackageDiscovery,PackageValidator, andPackageRegistryHandleras separate focused services. - Decision recorded: Normal discovery triggers are deferred through Symfony Messenger because validation can be expensive.
PackageDiscoveryRunnerremains the synchronous executor for the Messenger handler and explicit recovery paths. - Decision recorded: Explicit discovery triggers use the callable
PackageDiscoveryDispatcher; the manual CLI adapter ispackages:discover, with--run-nowreserved for recovery and maintenance. - Decision recorded: Cache rebuild discovery uses an optional Symfony cache warmer with
cache_warmuptrigger context and a cache-local lock file. - Decision recorded: The installer stages or extracts packages into their dedicated folders and then triggers discovery. Validation belongs to discovery/registry synchronization.
- Decision recorded: Package update execution is deferred. A later updater compares registry versions with manifest source metadata and uses a narrow Git-backed stub for package or system updates.
- Decision recorded: Package activation/deactivation owns runtime state changes and asset rebuild coordination. If the rebuild fails, affected package status changes are restored.
- Implemented: Package detail rendering, lifecycle review-plan composition, and lifecycle action application are separated behind a small admin facade so future Admin UI and API package surfaces can reuse or replace only the read-model or action layer they need.
- Decision recorded: Package dependencies are Studio package registry dependencies, not Composer/vendor dependencies. The
systempackage represents the root manifest/runtime package for dependency checks. - Decision recorded: Inactive but valid dependencies are included in the activation plan and activated with the requested package; missing, faulty, removed, or too-old dependencies block activation.
- Decision recorded: Single-active scope replacement applies to real filesystem packages under
packages/; virtual native records such assystemstay active as fallback providers. - Decision recorded: Failed package lifecycle operations roll back to the previous active/inactive state where practical and report the error through the operational action log.
- Decision recorded: Package loaders use
ActivePackageProvideras their lifecycle gate. Twig namespace registration and package asset sync already share this boundary, and future service, route, migration, provider, and scheduler loaders should do the same so inactive or virtual records cannot accidentally contribute runtime behavior. - Decision recorded: Active package PHP uses an explicit
package.phpruntime loader. Discovery never includes package PHP,src/files must stay below the declaredPACKAGE_NAMESPACEwhen present, and loader failures mark the packagefaultyinstead of surfacing raw request exceptions. - Decision recorded: Package Twig templates are trusted package code, but packages must use core-provided providers for sensitive user, ACL, secret, or protected configuration data. The validator and lifecycle policy should make these boundaries adjustable without building a heavy runtime sandbox.
- Implemented baseline: Package templates may override templates only in their declared template scopes. Within a template,
@rootreferences are always allowed, and scope namespaces are limited to the current template surface:templates/frontend/**may reference@frontend/**,templates/backend/**may reference@backend/**, andtemplates/provider/{provider}/**may reference matching@provider/{provider}/**. Cross-scope imports, includes, embeds, and extends are rejected during package validation. - Decision recorded: Public hook runtime failures mark an active package
faultyonly when the failing package can be identified. Identified faults also deactivate active reverse dependents with lifecycle messages so logs explain why dependent packages changed state. Unknown listener ownership remains a structured issue for logs/debugging. - Decision recorded: First-party modules use the same validator policy by default. If trusted first-party overrides become necessary, they should be explicit policy entries rather than bypassing validation entirely.
- Decision recorded: Package route registration happens during cache warmup or an explicit rebuild flow, not through magical runtime route changes.
- Decision recorded: Package removal deletes the package directory and keeps a
removedregistry row. Package purge invokes the cleanup boundary and then deletes the registry row; if the package directory still exists, later discovery can register it again. - Decision recorded: Replaceable providers such as captcha providers are first-class package scopes exposed through core-owned resolver contracts and explicit configuration.
- Decision recorded: Provider packages own their assets, templates, translations, and services. Core owns provider selection and fallback behavior.
- Decision recorded: Provider selection should stay resolver- and lifecycle-owned. A generic
ProviderCandidatesCollectEventis not planned unless a concrete provider surface proves that resolver contracts are insufficient. - Decision recorded: Provider rendering uses the deterministic
@provider/{provider-type}/**namespace. Active provider packages contributetemplates/provider/{provider-type}/**, and native fallbacks live in the same structure under the project template root. - Decision recorded: CodeMirror is the native base editor provider exposed through
@provider/editor/**; the native rich-text fallback is Markdown editing, and future WYSIWYG providers may replace only@provider/editor/richtext.html.twigwhile CodeMirror remains active for code-oriented editor aliases. - Decision recorded: Package lifecycle changes with frontend contributions trigger
assets:rebuildonce after the operation's package state changes are complete, including package asset sync before Tailwind and final cache clear after production-only AssetMapper compilation. Runtime exits from the active package set, includingfaultyorremovedstates discovered outside an explicit admin operation, queue the rebuild through Messenger instead of running it inline. Watchers are development-only convenience tooling. - Decision recorded: An unchanged
faultypackage is not revalidated by automatic discovery. It staysfaultyuntil discovery sees a meaningful package change such as a new manifest version/path, or until an administrator explicitly resets or repairs the lifecycle state. A successful revalidation returns the package toinactive, notactive; reactivation remains an explicit admin lifecycle operation. - Decision recorded: Fault recovery is separate from purge.
PackageFaultResetterprovides the soft admin repair path by validating the current package folder and movingfaultytoinactivewithout deleting package data, package files, or the registry row.PackageRemover::purge()remains the explicit destructive cleanup path for removed packages and future package-owned data cleanup. - Decision recorded: Simple package settings use one core-owned settings layer, not package-owned settings views. Packages register typed setting definitions with defaults and optional metadata; Studio renders the generic package settings UI under Admin Settings. Packages that need workflows beyond simple settings should provide their own Admin/Editor views outside the settings tree.
- Decision recorded: Package settings live under
/admin/settings/packages/{package-slug}. The package slug owns the route path, whilePACKAGE_NAMEis used for labels and titles./admin/settings/packagesis an overview page so empty states and all recognized package setting definitions remain debuggable. - Implemented baseline: Package-owned CSS uses stable owner/surface/scope naming. System-owned selectors use
system-*,system-frontend-*,system-backend-*, orsystem-{provider-scope}-*; package selectors use{package-slug}-*,{package-slug}-frontend-*,{package-slug}-backend-*, or{package-slug}-{provider-scope}-*.Studio/studiois product branding, not the default technical selector namespace. Package validation allows selectors to use classes from other scopes as context, but each CSS rule must target a package-owned class in the asset file's own scope so packages cannot silently redefinesystem-*or another package namespace. - Decision recorded: Runtime branding should come from
.manifest, branding assets, and future narrowly scoped branding packages instead of hardcoded technical identifiers. Branding packages remain deferred and should be documented as an optional future extension rather than implied by the first package lifecycle implementation.