feat: introduce module system and extract address book as first module (MODULAR-MONOLITH-V1-001)
Add a module kernel (ModuleManifest, ModuleRegistry) that allows modules to
contribute routes, UI slots, search providers, layout context, session
lifecycle, and permissions. Modules are activated via config/modules.php or
APP_ENABLED_MODULES env variable. Conflicts (duplicate routes, permissions,
slot keys) cause a fail-fast with a clear error message.
Extract the address book from hardcoded Core integration points into the
first module (modules/addressbook/). The module provides:
- Aside icon-bar tab + People panel via UI slot system
- Global search resource via AddressBookSearchProvider
- Layout context data via AddressBookLayoutProvider
- Session lifecycle via AddressBookSessionProvider
Core cleanup removes address-book hardcodings from SearchSqlResourceProvider,
SearchUiMetaProvider, SearchItemMapperProvider, appBuildLayoutNavContext(),
and the aside templates. Permissions (ADDRESS_BOOK_VIEW) and business logic
(AddressBookService) remain in Core as they gate general user visibility.
Includes 38 new tests (894 total), PHPStan level 5 clean, and architecture
tests verifying zero hardcoded address-book references in search/templates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:43:25 +01:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace MintyPHP\App\Module;
|
|
|
|
|
|
|
|
|
|
use InvalidArgumentException;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Immutable value object describing a single module's contributions.
|
|
|
|
|
*
|
|
|
|
|
* Each module ships a `module.php` that returns an associative array.
|
|
|
|
|
* ModuleManifest validates and normalises that array into a typed contract.
|
|
|
|
|
*/
|
|
|
|
|
final class ModuleManifest
|
|
|
|
|
{
|
|
|
|
|
public readonly string $id;
|
|
|
|
|
public readonly string $version;
|
|
|
|
|
public readonly bool $enabledByDefault;
|
|
|
|
|
public readonly int $loadOrder;
|
|
|
|
|
|
|
|
|
|
/** @var array<int, array{path: string, target: string, public?: bool}> */
|
|
|
|
|
public readonly array $routes;
|
|
|
|
|
|
|
|
|
|
/** @var list<string> */
|
|
|
|
|
public readonly array $publicPaths;
|
|
|
|
|
|
|
|
|
|
/** @var list<class-string> Container registrar FQCNs */
|
|
|
|
|
public readonly array $containerRegistrars;
|
|
|
|
|
|
|
|
|
|
/** @var array<string, mixed> Keyed by slot name (e.g. 'aside.tab_panel') */
|
|
|
|
|
public readonly array $uiSlots;
|
|
|
|
|
|
|
|
|
|
/** @var list<class-string> SearchResourceProvider FQCNs */
|
|
|
|
|
public readonly array $searchResources;
|
|
|
|
|
|
|
|
|
|
/** @var array<string, list<string>> Asset group name → file list */
|
|
|
|
|
public readonly array $assetGroups;
|
|
|
|
|
|
feat: extend module platform with UI slots, runtime components, CLI tooling and {{userId}} search support
Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.
New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering
New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:19:56 +01:00
|
|
|
/**
|
|
|
|
|
* @var list<array{
|
|
|
|
|
* job_key: string,
|
|
|
|
|
* handler: string,
|
|
|
|
|
* label: string,
|
|
|
|
|
* description: string,
|
|
|
|
|
* default_enabled: int,
|
|
|
|
|
* default_timezone: string,
|
|
|
|
|
* default_schedule_type: string,
|
|
|
|
|
* default_schedule_interval: int,
|
|
|
|
|
* default_schedule_time: ?string,
|
|
|
|
|
* default_schedule_weekdays_csv: ?string,
|
|
|
|
|
* default_catchup_once: int,
|
|
|
|
|
* allowed_schedule_types: list<string>
|
|
|
|
|
* }>
|
|
|
|
|
*/
|
feat: introduce module system and extract address book as first module (MODULAR-MONOLITH-V1-001)
Add a module kernel (ModuleManifest, ModuleRegistry) that allows modules to
contribute routes, UI slots, search providers, layout context, session
lifecycle, and permissions. Modules are activated via config/modules.php or
APP_ENABLED_MODULES env variable. Conflicts (duplicate routes, permissions,
slot keys) cause a fail-fast with a clear error message.
Extract the address book from hardcoded Core integration points into the
first module (modules/addressbook/). The module provides:
- Aside icon-bar tab + People panel via UI slot system
- Global search resource via AddressBookSearchProvider
- Layout context data via AddressBookLayoutProvider
- Session lifecycle via AddressBookSessionProvider
Core cleanup removes address-book hardcodings from SearchSqlResourceProvider,
SearchUiMetaProvider, SearchItemMapperProvider, appBuildLayoutNavContext(),
and the aside templates. Permissions (ADDRESS_BOOK_VIEW) and business logic
(AddressBookService) remain in Core as they gate general user visibility.
Includes 38 new tests (894 total), PHPStan level 5 clean, and architecture
tests verifying zero hardcoded address-book references in search/templates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:43:25 +01:00
|
|
|
public readonly array $schedulerJobs;
|
|
|
|
|
|
|
|
|
|
/** @var list<class-string> LayoutContextProvider FQCNs */
|
|
|
|
|
public readonly array $layoutContextProviders;
|
|
|
|
|
|
|
|
|
|
/** @var list<class-string> SessionProvider FQCNs */
|
|
|
|
|
public readonly array $sessionProviders;
|
|
|
|
|
|
feat: extend module platform with UI slots, runtime components, CLI tooling and {{userId}} search support
Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.
New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering
New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:19:56 +01:00
|
|
|
/**
|
|
|
|
|
* @var list<array{
|
|
|
|
|
* key: string,
|
|
|
|
|
* description: string,
|
|
|
|
|
* active: int,
|
|
|
|
|
* is_system: int
|
|
|
|
|
* }>
|
|
|
|
|
*/
|
feat: introduce module system and extract address book as first module (MODULAR-MONOLITH-V1-001)
Add a module kernel (ModuleManifest, ModuleRegistry) that allows modules to
contribute routes, UI slots, search providers, layout context, session
lifecycle, and permissions. Modules are activated via config/modules.php or
APP_ENABLED_MODULES env variable. Conflicts (duplicate routes, permissions,
slot keys) cause a fail-fast with a clear error message.
Extract the address book from hardcoded Core integration points into the
first module (modules/addressbook/). The module provides:
- Aside icon-bar tab + People panel via UI slot system
- Global search resource via AddressBookSearchProvider
- Layout context data via AddressBookLayoutProvider
- Session lifecycle via AddressBookSessionProvider
Core cleanup removes address-book hardcodings from SearchSqlResourceProvider,
SearchUiMetaProvider, SearchItemMapperProvider, appBuildLayoutNavContext(),
and the aside templates. Permissions (ADDRESS_BOOK_VIEW) and business logic
(AddressBookService) remain in Core as they gate general user visibility.
Includes 38 new tests (894 total), PHPStan level 5 clean, and architecture
tests verifying zero hardcoded address-book references in search/templates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:43:25 +01:00
|
|
|
public readonly array $permissions;
|
|
|
|
|
|
feat: extend module platform with UI slots, runtime components, CLI tooling and {{userId}} search support
Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.
New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering
New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:19:56 +01:00
|
|
|
/** @var list<class-string> AuthorizationPolicyInterface FQCNs */
|
|
|
|
|
public readonly array $authorizationPolicies;
|
|
|
|
|
|
2026-03-19 18:20:13 +01:00
|
|
|
/** @var list<string> Module IDs required by this module */
|
|
|
|
|
public readonly array $requires;
|
|
|
|
|
|
|
|
|
|
/** @var array<string, list<array{class: class-string, method: string}>> Event listeners keyed by event name */
|
|
|
|
|
public readonly array $eventListeners;
|
|
|
|
|
|
|
|
|
|
/** @var class-string|null ModuleDeactivationHandler FQCN */
|
|
|
|
|
public readonly ?string $deactivationHandler;
|
|
|
|
|
|
feat: introduce module system and extract address book as first module (MODULAR-MONOLITH-V1-001)
Add a module kernel (ModuleManifest, ModuleRegistry) that allows modules to
contribute routes, UI slots, search providers, layout context, session
lifecycle, and permissions. Modules are activated via config/modules.php or
APP_ENABLED_MODULES env variable. Conflicts (duplicate routes, permissions,
slot keys) cause a fail-fast with a clear error message.
Extract the address book from hardcoded Core integration points into the
first module (modules/addressbook/). The module provides:
- Aside icon-bar tab + People panel via UI slot system
- Global search resource via AddressBookSearchProvider
- Layout context data via AddressBookLayoutProvider
- Session lifecycle via AddressBookSessionProvider
Core cleanup removes address-book hardcodings from SearchSqlResourceProvider,
SearchUiMetaProvider, SearchItemMapperProvider, appBuildLayoutNavContext(),
and the aside templates. Permissions (ADDRESS_BOOK_VIEW) and business logic
(AddressBookService) remain in Core as they gate general user visibility.
Includes 38 new tests (894 total), PHPStan level 5 clean, and architecture
tests verifying zero hardcoded address-book references in search/templates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:43:25 +01:00
|
|
|
public readonly ?string $migrationsPath;
|
|
|
|
|
|
|
|
|
|
public readonly string $basePath;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param array<string, mixed> $raw The manifest array returned by module.php
|
|
|
|
|
* @param string $basePath Absolute path to the module directory
|
|
|
|
|
*/
|
|
|
|
|
private function __construct(array $raw, string $basePath)
|
|
|
|
|
{
|
|
|
|
|
$this->basePath = $basePath;
|
|
|
|
|
|
|
|
|
|
// — required fields ————————————————————————————
|
|
|
|
|
if (!isset($raw['id']) || !is_string($raw['id']) || trim($raw['id']) === '') {
|
|
|
|
|
throw new InvalidArgumentException('Module manifest must have a non-empty string "id".');
|
|
|
|
|
}
|
|
|
|
|
$this->id = trim($raw['id']);
|
|
|
|
|
|
|
|
|
|
$this->version = isset($raw['version']) && is_string($raw['version']) ? trim($raw['version']) : '0.0.0';
|
|
|
|
|
$this->enabledByDefault = (bool) ($raw['enabled_by_default'] ?? false);
|
|
|
|
|
$this->loadOrder = (int) ($raw['load_order'] ?? 100);
|
|
|
|
|
|
|
|
|
|
// — optional contribution arrays ———————————————
|
|
|
|
|
$this->routes = self::arrayOf($raw, 'routes');
|
|
|
|
|
$this->publicPaths = self::listOf($raw, 'public_paths');
|
|
|
|
|
$this->containerRegistrars = self::listOf($raw, 'container_registrars');
|
|
|
|
|
$this->uiSlots = is_array($raw['ui_slots'] ?? null) ? $raw['ui_slots'] : [];
|
|
|
|
|
$this->searchResources = self::listOf($raw, 'search_resources');
|
|
|
|
|
$this->assetGroups = is_array($raw['asset_groups'] ?? null) ? $raw['asset_groups'] : [];
|
feat: extend module platform with UI slots, runtime components, CLI tooling and {{userId}} search support
Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.
New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering
New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:19:56 +01:00
|
|
|
$this->schedulerJobs = self::normalizeSchedulerJobs($raw['scheduler_jobs'] ?? [], $this->id);
|
feat: introduce module system and extract address book as first module (MODULAR-MONOLITH-V1-001)
Add a module kernel (ModuleManifest, ModuleRegistry) that allows modules to
contribute routes, UI slots, search providers, layout context, session
lifecycle, and permissions. Modules are activated via config/modules.php or
APP_ENABLED_MODULES env variable. Conflicts (duplicate routes, permissions,
slot keys) cause a fail-fast with a clear error message.
Extract the address book from hardcoded Core integration points into the
first module (modules/addressbook/). The module provides:
- Aside icon-bar tab + People panel via UI slot system
- Global search resource via AddressBookSearchProvider
- Layout context data via AddressBookLayoutProvider
- Session lifecycle via AddressBookSessionProvider
Core cleanup removes address-book hardcodings from SearchSqlResourceProvider,
SearchUiMetaProvider, SearchItemMapperProvider, appBuildLayoutNavContext(),
and the aside templates. Permissions (ADDRESS_BOOK_VIEW) and business logic
(AddressBookService) remain in Core as they gate general user visibility.
Includes 38 new tests (894 total), PHPStan level 5 clean, and architecture
tests verifying zero hardcoded address-book references in search/templates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:43:25 +01:00
|
|
|
$this->layoutContextProviders = self::listOf($raw, 'layout_context_providers');
|
|
|
|
|
$this->sessionProviders = self::listOf($raw, 'session_providers');
|
feat: extend module platform with UI slots, runtime components, CLI tooling and {{userId}} search support
Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.
New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering
New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:19:56 +01:00
|
|
|
$this->permissions = self::normalizePermissions($raw['permissions'] ?? [], $this->id);
|
|
|
|
|
$this->authorizationPolicies = self::listOf($raw, 'authorization_policies');
|
feat: introduce module system and extract address book as first module (MODULAR-MONOLITH-V1-001)
Add a module kernel (ModuleManifest, ModuleRegistry) that allows modules to
contribute routes, UI slots, search providers, layout context, session
lifecycle, and permissions. Modules are activated via config/modules.php or
APP_ENABLED_MODULES env variable. Conflicts (duplicate routes, permissions,
slot keys) cause a fail-fast with a clear error message.
Extract the address book from hardcoded Core integration points into the
first module (modules/addressbook/). The module provides:
- Aside icon-bar tab + People panel via UI slot system
- Global search resource via AddressBookSearchProvider
- Layout context data via AddressBookLayoutProvider
- Session lifecycle via AddressBookSessionProvider
Core cleanup removes address-book hardcodings from SearchSqlResourceProvider,
SearchUiMetaProvider, SearchItemMapperProvider, appBuildLayoutNavContext(),
and the aside templates. Permissions (ADDRESS_BOOK_VIEW) and business logic
(AddressBookService) remain in Core as they gate general user visibility.
Includes 38 new tests (894 total), PHPStan level 5 clean, and architecture
tests verifying zero hardcoded address-book references in search/templates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:43:25 +01:00
|
|
|
|
2026-03-19 18:20:13 +01:00
|
|
|
$this->requires = self::normalizeRequires($raw['requires'] ?? [], $this->id);
|
|
|
|
|
$this->eventListeners = self::normalizeEventListeners($raw['event_listeners'] ?? [], $this->id);
|
|
|
|
|
|
|
|
|
|
$deactivationHandler = $raw['deactivation_handler'] ?? null;
|
|
|
|
|
$this->deactivationHandler = is_string($deactivationHandler) && trim($deactivationHandler) !== ''
|
|
|
|
|
? trim($deactivationHandler)
|
|
|
|
|
: null;
|
|
|
|
|
|
feat: introduce module system and extract address book as first module (MODULAR-MONOLITH-V1-001)
Add a module kernel (ModuleManifest, ModuleRegistry) that allows modules to
contribute routes, UI slots, search providers, layout context, session
lifecycle, and permissions. Modules are activated via config/modules.php or
APP_ENABLED_MODULES env variable. Conflicts (duplicate routes, permissions,
slot keys) cause a fail-fast with a clear error message.
Extract the address book from hardcoded Core integration points into the
first module (modules/addressbook/). The module provides:
- Aside icon-bar tab + People panel via UI slot system
- Global search resource via AddressBookSearchProvider
- Layout context data via AddressBookLayoutProvider
- Session lifecycle via AddressBookSessionProvider
Core cleanup removes address-book hardcodings from SearchSqlResourceProvider,
SearchUiMetaProvider, SearchItemMapperProvider, appBuildLayoutNavContext(),
and the aside templates. Permissions (ADDRESS_BOOK_VIEW) and business logic
(AddressBookService) remain in Core as they gate general user visibility.
Includes 38 new tests (894 total), PHPStan level 5 clean, and architecture
tests verifying zero hardcoded address-book references in search/templates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:43:25 +01:00
|
|
|
$migrationsPath = $raw['migrations_path'] ?? null;
|
|
|
|
|
$this->migrationsPath = is_string($migrationsPath) && trim($migrationsPath) !== ''
|
|
|
|
|
? rtrim($basePath, '/') . '/' . ltrim(trim($migrationsPath), '/')
|
|
|
|
|
: null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param array<string, mixed> $raw
|
|
|
|
|
*/
|
|
|
|
|
public static function fromArray(array $raw, string $basePath): self
|
|
|
|
|
{
|
|
|
|
|
return new self($raw, $basePath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return list<mixed>
|
|
|
|
|
*/
|
|
|
|
|
private static function listOf(array $raw, string $key): array
|
|
|
|
|
{
|
|
|
|
|
$value = $raw[$key] ?? [];
|
|
|
|
|
return is_array($value) ? array_values($value) : [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return array<int|string, mixed>
|
|
|
|
|
*/
|
|
|
|
|
private static function arrayOf(array $raw, string $key): array
|
|
|
|
|
{
|
|
|
|
|
$value = $raw[$key] ?? [];
|
|
|
|
|
return is_array($value) ? $value : [];
|
|
|
|
|
}
|
feat: extend module platform with UI slots, runtime components, CLI tooling and {{userId}} search support
Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.
New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering
New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:19:56 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return list<array{key: string, description: string, active: int, is_system: int}>
|
|
|
|
|
*/
|
|
|
|
|
private static function normalizePermissions(mixed $value, string $moduleId): array
|
|
|
|
|
{
|
|
|
|
|
if (!is_array($value)) {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
sprintf("Module '%s' manifest key 'permissions' must be an array.", $moduleId)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$permissions = [];
|
|
|
|
|
foreach (array_values($value) as $index => $permission) {
|
|
|
|
|
if (!is_array($permission)) {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
sprintf("Module '%s' permissions[%d] must be an object-like array.", $moduleId, $index)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$key = trim((string) ($permission['key'] ?? ''));
|
|
|
|
|
$description = trim((string) ($permission['description'] ?? ''));
|
|
|
|
|
if ($key === '' || $description === '') {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
sprintf("Module '%s' permissions[%d] must define non-empty 'key' and 'description'.", $moduleId, $index)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$permissions[] = [
|
|
|
|
|
'key' => $key,
|
|
|
|
|
'description' => $description,
|
|
|
|
|
'active' => (int) ((bool) ($permission['active'] ?? true)),
|
|
|
|
|
'is_system' => (int) ((bool) ($permission['is_system'] ?? true)),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $permissions;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return list<array{
|
|
|
|
|
* job_key: string,
|
|
|
|
|
* handler: string,
|
|
|
|
|
* label: string,
|
|
|
|
|
* description: string,
|
|
|
|
|
* default_enabled: int,
|
|
|
|
|
* default_timezone: string,
|
|
|
|
|
* default_schedule_type: string,
|
|
|
|
|
* default_schedule_interval: int,
|
|
|
|
|
* default_schedule_time: ?string,
|
|
|
|
|
* default_schedule_weekdays_csv: ?string,
|
|
|
|
|
* default_catchup_once: int,
|
|
|
|
|
* allowed_schedule_types: list<string>
|
|
|
|
|
* }>
|
|
|
|
|
*/
|
|
|
|
|
private static function normalizeSchedulerJobs(mixed $value, string $moduleId): array
|
|
|
|
|
{
|
|
|
|
|
if (!is_array($value)) {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
sprintf("Module '%s' manifest key 'scheduler_jobs' must be an array.", $moduleId)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$jobs = [];
|
|
|
|
|
foreach (array_values($value) as $index => $job) {
|
|
|
|
|
if (!is_array($job)) {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
sprintf("Module '%s' scheduler_jobs[%d] must be an object-like array.", $moduleId, $index)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$requiredKeys = [
|
|
|
|
|
'job_key',
|
|
|
|
|
'handler',
|
|
|
|
|
'label',
|
|
|
|
|
'description',
|
|
|
|
|
'default_enabled',
|
|
|
|
|
'default_timezone',
|
|
|
|
|
'default_schedule_type',
|
|
|
|
|
'default_schedule_interval',
|
|
|
|
|
'default_schedule_time',
|
|
|
|
|
'default_schedule_weekdays_csv',
|
|
|
|
|
'default_catchup_once',
|
|
|
|
|
'allowed_schedule_types',
|
|
|
|
|
];
|
|
|
|
|
foreach ($requiredKeys as $requiredKey) {
|
|
|
|
|
if (!array_key_exists($requiredKey, $job)) {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
sprintf("Module '%s' scheduler_jobs[%d] is missing required key '%s'.", $moduleId, $index, $requiredKey)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$jobKey = trim((string) ($job['job_key'] ?? ''));
|
|
|
|
|
$handler = trim((string) ($job['handler'] ?? ''));
|
|
|
|
|
$label = trim((string) ($job['label'] ?? ''));
|
|
|
|
|
$description = trim((string) ($job['description'] ?? ''));
|
|
|
|
|
$defaultTimezone = trim((string) ($job['default_timezone'] ?? ''));
|
|
|
|
|
$defaultType = strtolower(trim((string) ($job['default_schedule_type'] ?? '')));
|
|
|
|
|
|
|
|
|
|
if ($jobKey === '' || $handler === '' || $label === '' || $description === '' || $defaultTimezone === '') {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
sprintf(
|
|
|
|
|
"Module '%s' scheduler_jobs[%d] requires non-empty job_key, handler, label, description and default_timezone.",
|
|
|
|
|
$moduleId,
|
|
|
|
|
$index
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!in_array($defaultType, ['hourly', 'daily', 'weekly'], true)) {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
sprintf("Module '%s' scheduler_jobs[%d] has invalid default_schedule_type '%s'.", $moduleId, $index, $defaultType)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$allowedTypes = self::normalizeAllowedScheduleTypes($job['allowed_schedule_types'], $moduleId, $index);
|
|
|
|
|
if (!in_array($defaultType, $allowedTypes, true)) {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
sprintf(
|
|
|
|
|
"Module '%s' scheduler_jobs[%d] default_schedule_type '%s' must be listed in allowed_schedule_types.",
|
|
|
|
|
$moduleId,
|
|
|
|
|
$index,
|
|
|
|
|
$defaultType
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$defaultInterval = (int) $job['default_schedule_interval'];
|
|
|
|
|
$defaultTimeRaw = trim((string) ($job['default_schedule_time'] ?? ''));
|
|
|
|
|
$defaultTime = $defaultTimeRaw !== '' ? $defaultTimeRaw : null;
|
|
|
|
|
$defaultWeekdays = self::normalizeWeekdaysCsv(
|
|
|
|
|
$job['default_schedule_weekdays_csv'],
|
|
|
|
|
$moduleId,
|
|
|
|
|
$index
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (in_array($defaultType, ['daily', 'weekly'], true) && $defaultTime === null) {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
sprintf("Module '%s' scheduler_jobs[%d] requires default_schedule_time for %s jobs.", $moduleId, $index, $defaultType)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if ($defaultType === 'weekly' && $defaultWeekdays === null) {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
sprintf("Module '%s' scheduler_jobs[%d] requires default_schedule_weekdays_csv for weekly jobs.", $moduleId, $index)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$jobs[] = [
|
|
|
|
|
'job_key' => $jobKey,
|
|
|
|
|
'handler' => $handler,
|
|
|
|
|
'label' => $label,
|
|
|
|
|
'description' => $description,
|
|
|
|
|
'default_enabled' => (int) ((bool) $job['default_enabled']),
|
|
|
|
|
'default_timezone' => $defaultTimezone,
|
|
|
|
|
'default_schedule_type' => $defaultType,
|
|
|
|
|
'default_schedule_interval' => $defaultInterval,
|
|
|
|
|
'default_schedule_time' => $defaultTime,
|
|
|
|
|
'default_schedule_weekdays_csv' => $defaultWeekdays,
|
|
|
|
|
'default_catchup_once' => (int) ((bool) $job['default_catchup_once']),
|
|
|
|
|
'allowed_schedule_types' => $allowedTypes,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $jobs;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return list<string>
|
|
|
|
|
*/
|
|
|
|
|
private static function normalizeAllowedScheduleTypes(mixed $value, string $moduleId, int $index): array
|
|
|
|
|
{
|
|
|
|
|
if (!is_array($value) || $value === []) {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
sprintf("Module '%s' scheduler_jobs[%d] allowed_schedule_types must be a non-empty array.", $moduleId, $index)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$types = [];
|
|
|
|
|
foreach ($value as $rawType) {
|
|
|
|
|
$type = strtolower(trim((string) $rawType));
|
|
|
|
|
if (!in_array($type, ['hourly', 'daily', 'weekly'], true)) {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
sprintf("Module '%s' scheduler_jobs[%d] allowed_schedule_types contains invalid type '%s'.", $moduleId, $index, $type)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
$types[] = $type;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$types = array_values(array_unique($types));
|
|
|
|
|
sort($types, SORT_STRING);
|
|
|
|
|
return $types;
|
|
|
|
|
}
|
2026-03-19 18:20:13 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return array<string, list<array{class: class-string, method: string}>>
|
|
|
|
|
*/
|
|
|
|
|
private static function normalizeEventListeners(mixed $value, string $moduleId): array
|
|
|
|
|
{
|
|
|
|
|
if (!is_array($value)) {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
sprintf("Module '%s' manifest key 'event_listeners' must be an array.", $moduleId)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$listeners = [];
|
|
|
|
|
foreach ($value as $eventName => $handlers) {
|
|
|
|
|
$eventName = trim((string) $eventName);
|
|
|
|
|
if ($eventName === '') {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
sprintf("Module '%s' event_listeners has an empty event name key.", $moduleId)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!is_array($handlers)) {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
sprintf("Module '%s' event_listeners['%s'] must be an array of handler descriptors.", $moduleId, $eventName)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$normalizedHandlers = [];
|
|
|
|
|
foreach (array_values($handlers) as $index => $handler) {
|
|
|
|
|
if (!is_array($handler)) {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
sprintf("Module '%s' event_listeners['%s'][%d] must be an array with 'class' and 'method'.", $moduleId, $eventName, $index)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$class = trim((string) ($handler['class'] ?? ''));
|
|
|
|
|
$method = trim((string) ($handler['method'] ?? ''));
|
|
|
|
|
if ($class === '' || $method === '') {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
sprintf("Module '%s' event_listeners['%s'][%d] must define non-empty 'class' and 'method'.", $moduleId, $eventName, $index)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$normalizedHandlers[] = ['class' => $class, 'method' => $method];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($normalizedHandlers !== []) {
|
|
|
|
|
$listeners[$eventName] = $normalizedHandlers;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $listeners;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return list<string>
|
|
|
|
|
*/
|
|
|
|
|
private static function normalizeRequires(mixed $value, string $moduleId): array
|
|
|
|
|
{
|
|
|
|
|
if (!is_array($value)) {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
sprintf("Module '%s' manifest key 'requires' must be an array.", $moduleId)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$requires = [];
|
|
|
|
|
foreach (array_values($value) as $index => $item) {
|
|
|
|
|
if (!is_string($item) || trim($item) === '') {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
sprintf("Module '%s' requires[%d] must be a non-empty string.", $moduleId, $index)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
$requires[] = trim($item);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return array_values(array_unique($requires));
|
|
|
|
|
}
|
feat: extend module platform with UI slots, runtime components, CLI tooling and {{userId}} search support
Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.
New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering
New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:19:56 +01:00
|
|
|
|
|
|
|
|
private static function normalizeWeekdaysCsv(mixed $value, string $moduleId, int $index): ?string
|
|
|
|
|
{
|
|
|
|
|
$raw = trim((string) $value);
|
|
|
|
|
if ($raw === '') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$days = array_filter(array_map('trim', explode(',', $raw)), static fn (string $part): bool => $part !== '');
|
|
|
|
|
if ($days === []) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$normalizedDays = [];
|
|
|
|
|
foreach ($days as $day) {
|
|
|
|
|
if (!preg_match('/^[1-7]$/', $day)) {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
sprintf("Module '%s' scheduler_jobs[%d] has invalid weekday '%s' in default_schedule_weekdays_csv.", $moduleId, $index, $day)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
$normalizedDays[] = (int) $day;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$normalizedDays = array_values(array_unique($normalizedDays));
|
|
|
|
|
sort($normalizedDays, SORT_NUMERIC);
|
|
|
|
|
return implode(',', array_map(static fn (int $day): string => (string) $day, $normalizedDays));
|
|
|
|
|
}
|
feat: introduce module system and extract address book as first module (MODULAR-MONOLITH-V1-001)
Add a module kernel (ModuleManifest, ModuleRegistry) that allows modules to
contribute routes, UI slots, search providers, layout context, session
lifecycle, and permissions. Modules are activated via config/modules.php or
APP_ENABLED_MODULES env variable. Conflicts (duplicate routes, permissions,
slot keys) cause a fail-fast with a clear error message.
Extract the address book from hardcoded Core integration points into the
first module (modules/addressbook/). The module provides:
- Aside icon-bar tab + People panel via UI slot system
- Global search resource via AddressBookSearchProvider
- Layout context data via AddressBookLayoutProvider
- Session lifecycle via AddressBookSessionProvider
Core cleanup removes address-book hardcodings from SearchSqlResourceProvider,
SearchUiMetaProvider, SearchItemMapperProvider, appBuildLayoutNavContext(),
and the aside templates. Permissions (ADDRESS_BOOK_VIEW) and business logic
(AddressBookService) remain in Core as they gate general user visibility.
Includes 38 new tests (894 total), PHPStan level 5 clean, and architecture
tests verifying zero hardcoded address-book references in search/templates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:43:25 +01:00
|
|
|
}
|