Skip to content

Latest commit

 

History

History
181 lines (166 loc) · 29.4 KB

File metadata and controls

181 lines (166 loc) · 29.4 KB

Package modules and providers (Feature Draft)

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.

Overview

  • Defines how packages with module, captcha-provider, or editor-provider scopes 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.

Outline

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.

Technical Specifications

  • Use .manifest-style metadata with PACKAGE_ keys.
  • Use the package layout packages/<package-slug>/ with .manifest, optional package.php, src/, config/, templates/, assets/, languages/, and migrations/ where needed.
  • Suggested required keys:
    • PACKAGE_AUTHOR
    • PACKAGE_SLUG
    • PACKAGE_NAME
    • PACKAGE_VERSION
    • PACKAGE_SCOPE
    • PACKAGE_DEPENDENCIES
  • Suggested optional keys:
    • PACKAGE_DESCRIPTION
    • PACKAGE_NAMESPACE
    • PACKAGE_IMAGE
    • PACKAGE_LICENSE
    • PACKAGE_HOMEPAGE
    • PACKAGE_SOURCE
    • PACKAGE_CHANNEL
  • PACKAGE_SCOPE values are DotEnv-style lists, for example [module, captcha-provider].
  • Current allowed scopes are module, captcha-provider, and editor-provider for this draft; frontend-theme, backend-theme, and system-template are covered by the theme draft.
  • A package is activated or deactivated as one unit. If a package combines module with 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_DEPENDENCIES lists 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 the packages:discover command.
  • 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 marked removed, valid version/path/manifest mismatches update metadata, and validation failures are marked faulty.
  • 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 below pkg.<package-slug>.*, and only active package language files are aggregated into the generated runtime messages catalogue.
  • 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\PublicEventHookRegistry as 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\PublicEventDispatcher so unregistered hooks and listener failures become structured operation issues instead of raw exceptions.
  • Core hook failures emit App\Core\Event\PublicHookFailedEvent as 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 faulty when 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}.yaml before cache clearing. Inactive package translations under packages/ 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 @source directives and feed active package CSS into Tailwind with generated @import directives 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:rebuild after package install, activation, deactivation, update, uninstall, or runtime state exits such as active packages becoming faulty or removed. 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:clear should 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 system package 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.php only for active, valid packages. The loader may require package-owned PHP below src/, and hard loader or runtime-provider failures should be recorded through lifecycle diagnostics, mark the package faulty, 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.

Testing & Validation

  • 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:container after service integration changes.
  • Add integration fixtures for one minimal first-party package and one intentionally invalid package.

Implementation Notes

  • 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 .manifest KEY=VALUE format 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, optional package.php, src/, config/, templates/, assets/, languages/, and migrations/.
  • 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 under packages/{package-slug}. Human-readable PACKAGE_NAME stays 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, PackageValidator validation, and registry reconciliation, even if the implementation keeps PackageDiscovery, PackageValidator, and PackageRegistryHandler as separate focused services.
  • Decision recorded: Normal discovery triggers are deferred through Symfony Messenger because validation can be expensive. PackageDiscoveryRunner remains the synchronous executor for the Messenger handler and explicit recovery paths.
  • Decision recorded: Explicit discovery triggers use the callable PackageDiscoveryDispatcher; the manual CLI adapter is packages:discover, with --run-now reserved for recovery and maintenance.
  • Decision recorded: Cache rebuild discovery uses an optional Symfony cache warmer with cache_warmup trigger 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 system package 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 as system stay 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 ActivePackageProvider as 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.php runtime loader. Discovery never includes package PHP, src/ files must stay below the declared PACKAGE_NAMESPACE when present, and loader failures mark the package faulty instead 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, @root references are always allowed, and scope namespaces are limited to the current template surface: templates/frontend/** may reference @frontend/**, templates/backend/** may reference @backend/**, and templates/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 faulty only 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 removed registry 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 ProviderCandidatesCollectEvent is 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 contribute templates/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.twig while CodeMirror remains active for code-oriented editor aliases.
  • Decision recorded: Package lifecycle changes with frontend contributions trigger assets:rebuild once 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, including faulty or removed states 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 faulty package is not revalidated by automatic discovery. It stays faulty until 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 to inactive, not active; reactivation remains an explicit admin lifecycle operation.
  • Decision recorded: Fault recovery is separate from purge. PackageFaultResetter provides the soft admin repair path by validating the current package folder and moving faulty to inactive without 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, while PACKAGE_NAME is used for labels and titles. /admin/settings/packages is 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-*, or system-{provider-scope}-*; package selectors use {package-slug}-*, {package-slug}-frontend-*, {package-slug}-backend-*, or {package-slug}-{provider-scope}-*. Studio/studio is 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 redefine system-* 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.