From 1873ba0cbe70cc52089d360269c786a5416c46c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Biarda?= <1135380+michalbiarda@users.noreply.github.com> Date: Wed, 20 May 2026 15:06:33 +0200 Subject: [PATCH 1/3] feat(core): add module-declared global middleware support Packages can now declare global HTTP middleware in module.php via the `globalMiddleware` key (flat class-strings or array with class+priority). GlobalMiddlewareResolver merges module entries with built-in defaults, sorts by priority (ascending), and deduplicates using source priority (app > modules > vendor). Built-in GLOBAL_MIDDLEWARE entries retain silent-skip behavior for backwards compatibility. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + packages/core/src/Application.php | 23 +- .../core/src/Exceptions/ModuleException.php | 23 + .../src/Module/GlobalMiddlewareResolver.php | 179 ++++++++ packages/core/src/Module/ManifestParser.php | 1 + packages/core/src/Module/ModuleManifest.php | 2 + packages/core/tests/Unit/ApplicationTest.php | 30 +- .../Module/GlobalMiddlewareResolverTest.php | 408 ++++++++++++++++++ .../tests/Unit/Module/ModuleDiscoveryTest.php | 42 ++ 9 files changed, 678 insertions(+), 31 deletions(-) create mode 100644 packages/core/src/Module/GlobalMiddlewareResolver.php create mode 100644 packages/core/tests/Unit/Module/GlobalMiddlewareResolverTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 28abbd34..4116f32c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Entries from `0.4.0` onward are generated automatically by `bin/release.sh` from ### New Features * feat: add marko/page-cache and marko/page-cache-file packages +* feat(core): add module-declared global middleware support via globalMiddleware key in module.php ## [0.5.0] - 2026-05-01 diff --git a/packages/core/src/Application.php b/packages/core/src/Application.php index 9b3caba7..8232b887 100644 --- a/packages/core/src/Application.php +++ b/packages/core/src/Application.php @@ -26,6 +26,7 @@ use Marko\Core\Exceptions\PluginException; use Marko\Core\Exceptions\PreferenceConflictException; use Marko\Core\Module\DependencyResolver; +use Marko\Core\Module\GlobalMiddlewareResolver; use Marko\Core\Module\ManifestParser; use Marko\Core\Module\ModuleDiscovery; use Marko\Core\Module\ModuleManifest; @@ -303,11 +304,6 @@ private function discoverCommands(): void $this->commandRunner = new CommandRunner($this->container, $this->commandRegistry); } - private const array GLOBAL_MIDDLEWARE = [ - 'Marko\\PageCache\\Middleware\\PageCacheMiddleware', - 'Marko\\Session\\Middleware\\SessionMiddleware', - 'Marko\\Layout\\Middleware\\LayoutMiddleware', - ]; /** * @throws RouteException|RouteConflictException|ReflectionException @@ -348,20 +344,19 @@ public function handleRequest(): void } /** - * Discover available global middleware classes. + * Discover available global middleware classes by merging module-declared + * entries with the built-in hardcoded list, sorted by priority. * * @return array> + * @throws ModuleException When a module-declared middleware class is invalid */ private function discoverGlobalMiddleware(): array { - $middleware = []; + $resolver = new GlobalMiddlewareResolver(); - foreach (self::GLOBAL_MIDDLEWARE as $class) { - if (class_exists($class)) { - $middleware[] = $class; - } - } - - return $middleware; + return $resolver->resolve( + modules: $this->modules, + builtIns: GlobalMiddlewareResolver::DEFAULT_BUILT_INS, + ); } } diff --git a/packages/core/src/Exceptions/ModuleException.php b/packages/core/src/Exceptions/ModuleException.php index 6188600e..0cec9159 100644 --- a/packages/core/src/Exceptions/ModuleException.php +++ b/packages/core/src/Exceptions/ModuleException.php @@ -27,4 +27,27 @@ public static function missingDependency( suggestion: "Install the missing package with: composer require $dependencyName", ); } + + public static function invalidMiddlewareClass( + string $moduleName, + string $className, + string $reason, + ): self { + return new self( + message: "Invalid globalMiddleware entry in module '$moduleName': $reason", + context: "While resolving global middleware for '$moduleName'", + suggestion: "Ensure '$className' exists and implements " . \Marko\Routing\Middleware\MiddlewareInterface::class, + ); + } + + public static function invalidMiddlewareEntry( + string $moduleName, + string $reason, + ): self { + return new self( + message: "Invalid globalMiddleware entry in module '$moduleName': $reason", + context: "While resolving global middleware for '$moduleName'", + suggestion: "Each entry must be a class-string or ['class' => SomeMiddleware::class, 'priority' => 10]", + ); + } } diff --git a/packages/core/src/Module/GlobalMiddlewareResolver.php b/packages/core/src/Module/GlobalMiddlewareResolver.php new file mode 100644 index 00000000..278fbe72 --- /dev/null +++ b/packages/core/src/Module/GlobalMiddlewareResolver.php @@ -0,0 +1,179 @@ + modules > vendor). + */ +class GlobalMiddlewareResolver +{ + /** + * Default built-in global middleware with their priorities. + * + * These entries use skipIfMissing = true so apps without the optional + * packages (page-cache, session, layout) continue to boot unchanged. + * + * @var array + */ + public const array DEFAULT_BUILT_INS = [ + [ + 'class' => 'Marko\\PageCache\\Middleware\\PageCacheMiddleware', + 'priority' => 10, + 'source' => 'vendor', + 'skipIfMissing' => true, + ], + [ + 'class' => 'Marko\\Session\\Middleware\\SessionMiddleware', + 'priority' => 20, + 'source' => 'vendor', + 'skipIfMissing' => true, + ], + [ + 'class' => 'Marko\\Layout\\Middleware\\LayoutMiddleware', + 'priority' => 30, + 'source' => 'vendor', + 'skipIfMissing' => true, + ], + ]; + + private const array SOURCE_PRIORITY = [ + 'vendor' => 0, + 'modules' => 1, + 'app' => 2, + ]; + + private const int DEFAULT_PRIORITY = 100; + + /** + * Resolve global middleware from module declarations and built-in entries. + * + * @param array $modules + * @param array $builtIns + * @return array> + * @throws ModuleException When a module-declared class does not exist, is missing the class key, or does not implement MiddlewareInterface + */ + public function resolve( + array $modules, + array $builtIns, + ): array { + // Candidate map: class => [priority, sourcePriority] + // We keep the highest-source-priority entry; within same source, the lowest priority value. + /** @var array $candidates */ + $candidates = []; + + // Process built-in entries first (lowest source priority = vendor) + foreach ($builtIns as $builtIn) { + $class = $builtIn['class']; + $skipIfMissing = $builtIn['skipIfMissing'] ?? false; + + if (!class_exists($class)) { + if ($skipIfMissing) { + continue; + } + } + + $sourcePriority = self::SOURCE_PRIORITY[$builtIn['source']] ?? 0; + $this->addCandidate($candidates, $class, $builtIn['priority'], $sourcePriority); + } + + // Process module-declared entries + foreach ($modules as $module) { + $sourcePriority = self::SOURCE_PRIORITY[$module->source] ?? 0; + + foreach ($module->globalMiddleware as $entry) { + [$class, $priority] = $this->parseEntry($entry, $module->name); + + if (!class_exists($class)) { + throw ModuleException::invalidMiddlewareClass( + moduleName: $module->name, + className: $class, + reason: "Class '$class' does not exist", + ); + } + + if (!is_a($class, MiddlewareInterface::class, true)) { + throw ModuleException::invalidMiddlewareClass( + moduleName: $module->name, + className: $class, + reason: "Class '$class' does not implement " . MiddlewareInterface::class, + ); + } + + $this->addCandidate($candidates, $class, $priority, $sourcePriority); + } + } + + // Sort by priority ascending + uasort($candidates, fn (array $a, array $b) => $a['priority'] <=> $b['priority']); + + return array_keys($candidates); + } + + /** + * Parse a single globalMiddleware entry (flat string or array form). + * + * @param mixed $entry + * @return array{0: string, 1: int} + * @throws ModuleException When entry is invalid + */ + private function parseEntry( + mixed $entry, + string $moduleName, + ): array { + if (is_string($entry)) { + return [$entry, self::DEFAULT_PRIORITY]; + } + + if (!is_array($entry) || !isset($entry['class'])) { + throw ModuleException::invalidMiddlewareEntry( + moduleName: $moduleName, + reason: "Each globalMiddleware entry must be a class-string or an array with a 'class' key", + ); + } + + return [$entry['class'], $entry['priority'] ?? self::DEFAULT_PRIORITY]; + } + + /** + * Add or update a candidate entry applying deduplication rules. + * + * Rules: + * - Higher source priority always wins (app > modules > vendor) + * - Within the same source, keep the lowest priority value (runs earliest) + * + * @param array $candidates + */ + private function addCandidate( + array &$candidates, + string $class, + int $priority, + int $sourcePriority, + ): void { + if (!isset($candidates[$class])) { + $candidates[$class] = ['priority' => $priority, 'sourcePriority' => $sourcePriority]; + return; + } + + $existing = $candidates[$class]; + + if ($sourcePriority > $existing['sourcePriority']) { + // Higher source priority wins unconditionally + $candidates[$class] = ['priority' => $priority, 'sourcePriority' => $sourcePriority]; + return; + } + + if ($sourcePriority === $existing['sourcePriority'] && $priority < $existing['priority']) { + // Same source: keep the lower priority value (runs earlier) + $candidates[$class]['priority'] = $priority; + } + } +} diff --git a/packages/core/src/Module/ManifestParser.php b/packages/core/src/Module/ManifestParser.php index af39801d..f50397d6 100644 --- a/packages/core/src/Module/ManifestParser.php +++ b/packages/core/src/Module/ManifestParser.php @@ -40,6 +40,7 @@ public function parse( singletons: $moduleData['singletons'] ?? [], autoload: $composerData['autoload']['psr-4'] ?? [], boot: $moduleData['boot'] ?? null, + globalMiddleware: $moduleData['globalMiddleware'] ?? [], ); } diff --git a/packages/core/src/Module/ModuleManifest.php b/packages/core/src/Module/ModuleManifest.php index f9755831..94202ad7 100644 --- a/packages/core/src/Module/ModuleManifest.php +++ b/packages/core/src/Module/ModuleManifest.php @@ -27,6 +27,7 @@ * @param string $source Discovery source: vendor, modules, or app * @param array $autoload PSR-4 autoload configuration from composer.json (namespace => path) * @param Closure|null $boot Boot callback to run after bindings are registered (from module.php). Parameters are auto-injected from the container — type-hint any registered dependency, including ContainerInterface. + * @param array $globalMiddleware Global HTTP middleware declared by this module (from module.php) */ public function __construct( public string $name, @@ -41,5 +42,6 @@ public function __construct( public string $source = '', public array $autoload = [], public ?Closure $boot = null, + public array $globalMiddleware = [], ) {} } diff --git a/packages/core/tests/Unit/ApplicationTest.php b/packages/core/tests/Unit/ApplicationTest.php index 08c42fe8..c8f2e023 100644 --- a/packages/core/tests/Unit/ApplicationTest.php +++ b/packages/core/tests/Unit/ApplicationTest.php @@ -2005,34 +2005,30 @@ public function run(string \$result): string appTestCleanupDirectory($baseDir); }); -it('includes SessionMiddleware in global middleware', function (): void { - $reflection = new ReflectionClass(Application::class); - $constant = $reflection->getReflectionConstant('GLOBAL_MIDDLEWARE'); +it('includes SessionMiddleware in global middleware built-ins', function (): void { + $classes = array_column(Marko\Core\Module\GlobalMiddlewareResolver::DEFAULT_BUILT_INS, 'class'); - expect($constant->getValue())->toContain('Marko\\Session\\Middleware\\SessionMiddleware'); + expect($classes)->toContain('Marko\\Session\\Middleware\\SessionMiddleware'); }); -it('includes LayoutMiddleware in global middleware', function (): void { - $reflection = new ReflectionClass(Application::class); - $constant = $reflection->getReflectionConstant('GLOBAL_MIDDLEWARE'); +it('includes LayoutMiddleware in global middleware built-ins', function (): void { + $classes = array_column(Marko\Core\Module\GlobalMiddlewareResolver::DEFAULT_BUILT_INS, 'class'); - expect($constant->getValue())->toContain('Marko\\Layout\\Middleware\\LayoutMiddleware'); + expect($classes)->toContain('Marko\\Layout\\Middleware\\LayoutMiddleware'); }); -it('includes PageCacheMiddleware in global middleware', function (): void { - $reflection = new ReflectionClass(Application::class); - $constant = $reflection->getReflectionConstant('GLOBAL_MIDDLEWARE'); +it('includes PageCacheMiddleware in global middleware built-ins', function (): void { + $classes = array_column(Marko\Core\Module\GlobalMiddlewareResolver::DEFAULT_BUILT_INS, 'class'); - expect($constant->getValue())->toContain('Marko\\PageCache\\Middleware\\PageCacheMiddleware'); + expect($classes)->toContain('Marko\\PageCache\\Middleware\\PageCacheMiddleware'); }); it('lists PageCacheMiddleware before SessionMiddleware in global middleware order', function (): void { - $reflection = new ReflectionClass(Application::class); - $constant = $reflection->getReflectionConstant('GLOBAL_MIDDLEWARE'); - $middleware = $constant->getValue(); + $builtIns = Marko\Core\Module\GlobalMiddlewareResolver::DEFAULT_BUILT_INS; + $classes = array_column($builtIns, 'class'); - $pageCache = array_search('Marko\\PageCache\\Middleware\\PageCacheMiddleware', $middleware); - $session = array_search('Marko\\Session\\Middleware\\SessionMiddleware', $middleware); + $pageCache = array_search('Marko\\PageCache\\Middleware\\PageCacheMiddleware', $classes); + $session = array_search('Marko\\Session\\Middleware\\SessionMiddleware', $classes); expect($pageCache)->toBeLessThan($session); }); diff --git a/packages/core/tests/Unit/Module/GlobalMiddlewareResolverTest.php b/packages/core/tests/Unit/Module/GlobalMiddlewareResolverTest.php new file mode 100644 index 00000000..e0650d00 --- /dev/null +++ b/packages/core/tests/Unit/Module/GlobalMiddlewareResolverTest.php @@ -0,0 +1,408 @@ +resolve([$module], builtIns: []); + + expect($result) + ->toContain('Acme\Mw\AlphaMiddleware') + ->toContain('Acme\Mw\BetaMiddleware'); +}); + +// --------------------------------------------------------------------------- +// Requirement 2: array form with class key and priority +// --------------------------------------------------------------------------- + +it('accepts globalMiddleware entries as array with class key and priority', function (): void { + makeMiddlewareClass('Acme\Mw\GammaMiddleware'); + + $module = makeModuleManifest( + name: 'acme/test', + source: 'vendor', + globalMiddleware: [ + ['class' => 'Acme\Mw\GammaMiddleware', 'priority' => 25], + ], + ); + + $resolver = new GlobalMiddlewareResolver(); + $result = $resolver->resolve([$module], builtIns: []); + + expect($result)->toContain('Acme\Mw\GammaMiddleware'); +}); + +// --------------------------------------------------------------------------- +// Requirement 3: default priority 100 for flat entries +// --------------------------------------------------------------------------- + +it('defaults missing priority to 100', function (): void { + makeMiddlewareClass('Acme\Mw\DeltaMiddleware'); + makeMiddlewareClass('Acme\Mw\EpsilonMiddleware'); + + // DeltaMiddleware is flat (default priority 100) + // EpsilonMiddleware has priority 50 — should come first + $module = makeModuleManifest( + name: 'acme/test', + source: 'vendor', + globalMiddleware: [ + 'Acme\Mw\DeltaMiddleware', + ['class' => 'Acme\Mw\EpsilonMiddleware', 'priority' => 50], + ], + ); + + $resolver = new GlobalMiddlewareResolver(); + $result = $resolver->resolve([$module], builtIns: []); + + $deltaIndex = array_search('Acme\Mw\DeltaMiddleware', $result); + $epsilonIndex = array_search('Acme\Mw\EpsilonMiddleware', $result); + + // EpsilonMiddleware (priority 50) should come before DeltaMiddleware (priority 100) + expect($epsilonIndex)->toBeLessThan($deltaIndex); +}); + +// --------------------------------------------------------------------------- +// Requirement 4: merges with built-in hardcoded list +// --------------------------------------------------------------------------- + +it('merges module-declared globalMiddleware with built-in hardcoded list', function (): void { + makeMiddlewareClass('Acme\Mw\ZetaMiddleware'); + + $module = makeModuleManifest( + name: 'acme/test', + source: 'vendor', + globalMiddleware: ['Acme\Mw\ZetaMiddleware'], + ); + + $builtIns = [ + ['class' => 'Acme\Mw\ZetaMiddleware', 'priority' => 10, 'source' => 'vendor'], // same class won't duplicate + ]; + + // Use different built-in so we can verify both appear + makeMiddlewareClass('Acme\BuiltIn\BuiltInMiddleware'); + $builtInsClean = [ + ['class' => 'Acme\BuiltIn\BuiltInMiddleware', 'priority' => 10, 'source' => 'vendor'], + ]; + + $resolver = new GlobalMiddlewareResolver(); + $result = $resolver->resolve([$module], builtIns: $builtInsClean); + + expect($result) + ->toContain('Acme\Mw\ZetaMiddleware') + ->toContain('Acme\BuiltIn\BuiltInMiddleware'); +}); + +// --------------------------------------------------------------------------- +// Requirement 5: sorts merged result by priority ascending +// --------------------------------------------------------------------------- + +it('sorts merged globalMiddleware by priority ascending', function (): void { + makeMiddlewareClass('Acme\Mw\EtaMiddleware'); // priority 30 + makeMiddlewareClass('Acme\Mw\ThetaMiddleware'); // priority 10 (built-in) + makeMiddlewareClass('Acme\Mw\IotaMiddleware'); // priority 20 + + $module = makeModuleManifest( + name: 'acme/test', + source: 'vendor', + globalMiddleware: [ + ['class' => 'Acme\Mw\EtaMiddleware', 'priority' => 30], + ['class' => 'Acme\Mw\IotaMiddleware', 'priority' => 20], + ], + ); + + $builtIns = [ + ['class' => 'Acme\Mw\ThetaMiddleware', 'priority' => 10, 'source' => 'vendor'], + ]; + + $resolver = new GlobalMiddlewareResolver(); + $result = $resolver->resolve([$module], builtIns: $builtIns); + + $thetaIndex = array_search('Acme\Mw\ThetaMiddleware', $result); + $iotaIndex = array_search('Acme\Mw\IotaMiddleware', $result); + $etaIndex = array_search('Acme\Mw\EtaMiddleware', $result); + + expect($thetaIndex)->toBeLessThan($iotaIndex) + ->and($iotaIndex)->toBeLessThan($etaIndex); +}); + +// --------------------------------------------------------------------------- +// Requirement 6: deduplication — app > modules > vendor source priority +// --------------------------------------------------------------------------- + +it('deduplicates globalMiddleware entries preferring app over modules over vendor source', function (): void { + makeMiddlewareClass('Acme\Mw\KappaMiddleware'); + + $vendorModule = makeModuleManifest( + name: 'acme/vendor-mod', + source: 'vendor', + globalMiddleware: [ + ['class' => 'Acme\Mw\KappaMiddleware', 'priority' => 10], + ], + ); + + $appModule = makeModuleManifest( + name: 'acme/app-mod', + source: 'app', + globalMiddleware: [ + ['class' => 'Acme\Mw\KappaMiddleware', 'priority' => 50], + ], + ); + + $resolver = new GlobalMiddlewareResolver(); + $result = $resolver->resolve([$vendorModule, $appModule], builtIns: []); + + // Should appear only once — app source wins + $count = count(array_filter($result, fn ($c) => $c === 'Acme\Mw\KappaMiddleware')); + expect($count)->toBe(1); + // App entry has priority 50 — but app source always wins regardless + expect($result)->toContain('Acme\Mw\KappaMiddleware'); +}); + +// --------------------------------------------------------------------------- +// Requirement 7: within same source, keep lowest priority value +// --------------------------------------------------------------------------- + +it('deduplicates globalMiddleware within the same source by keeping the lowest priority value', function (): void { + makeMiddlewareClass('Acme\Mw\LambdaMiddleware'); + makeMiddlewareClass('Acme\Mw\MuMiddleware'); + + $module1 = makeModuleManifest( + name: 'acme/mod-a', + source: 'vendor', + globalMiddleware: [ + ['class' => 'Acme\Mw\LambdaMiddleware', 'priority' => 80], + ['class' => 'Acme\Mw\MuMiddleware', 'priority' => 5], + ], + ); + + $module2 = makeModuleManifest( + name: 'acme/mod-b', + source: 'vendor', + globalMiddleware: [ + ['class' => 'Acme\Mw\LambdaMiddleware', 'priority' => 40], // lower → should win + ], + ); + + $resolver = new GlobalMiddlewareResolver(); + $result = $resolver->resolve([$module1, $module2], builtIns: []); + + // LambdaMiddleware should appear once, with priority 40 (comes before MuMiddleware priority 5? no) + $count = count(array_filter($result, fn ($c) => $c === 'Acme\Mw\LambdaMiddleware')); + expect($count)->toBe(1); + + $lambdaIndex = array_search('Acme\Mw\LambdaMiddleware', $result); + $muIndex = array_search('Acme\Mw\MuMiddleware', $result); + + // MuMiddleware has priority 5, LambdaMiddleware's kept priority is 40 → mu comes first + expect($muIndex)->toBeLessThan($lambdaIndex); +}); + +// --------------------------------------------------------------------------- +// Requirement 8: built-in priorities +// --------------------------------------------------------------------------- + +it('assigns priority 10 to PageCacheMiddleware 20 to SessionMiddleware 30 to LayoutMiddleware as built-in defaults', function (): void { + // Make built-in classes exist for this test + makeMiddlewareClass('Marko\PageCache\Middleware\PageCacheMiddleware'); + makeMiddlewareClass('Marko\Session\Middleware\SessionMiddleware'); + makeMiddlewareClass('Marko\Layout\Middleware\LayoutMiddleware'); + + $resolver = new GlobalMiddlewareResolver(); + $result = $resolver->resolve([], builtIns: GlobalMiddlewareResolver::DEFAULT_BUILT_INS); + + $pageCacheIndex = array_search('Marko\PageCache\Middleware\PageCacheMiddleware', $result); + $sessionIndex = array_search('Marko\Session\Middleware\SessionMiddleware', $result); + $layoutIndex = array_search('Marko\Layout\Middleware\LayoutMiddleware', $result); + + // All should be present and ordered: PageCache (10) < Session (20) < Layout (30) + expect($pageCacheIndex)->not->toBeFalse() + ->and($sessionIndex)->not->toBeFalse() + ->and($layoutIndex)->not->toBeFalse() + ->and($pageCacheIndex)->toBeLessThan($sessionIndex) + ->and($sessionIndex)->toBeLessThan($layoutIndex); +}); + +// --------------------------------------------------------------------------- +// Requirement 9: returns class-string array in priority order +// --------------------------------------------------------------------------- + +it('returns class-string array from discoverGlobalMiddleware in priority order', function (): void { + makeMiddlewareClass('Acme\Mw\NuMiddleware'); + makeMiddlewareClass('Acme\Mw\XiMiddleware'); + + $module = makeModuleManifest( + name: 'acme/test', + source: 'vendor', + globalMiddleware: [ + ['class' => 'Acme\Mw\NuMiddleware', 'priority' => 200], + ['class' => 'Acme\Mw\XiMiddleware', 'priority' => 5], + ], + ); + + $resolver = new GlobalMiddlewareResolver(); + $result = $resolver->resolve([$module], builtIns: []); + + expect($result)->toBeArray(); + foreach ($result as $entry) { + expect($entry)->toBeString(); + } + + $nuIndex = array_search('Acme\Mw\NuMiddleware', $result); + $xiIndex = array_search('Acme\Mw\XiMiddleware', $result); + + // XiMiddleware (priority 5) should come before NuMiddleware (priority 200) + expect($xiIndex)->toBeLessThan($nuIndex); +}); + +// --------------------------------------------------------------------------- +// Requirement 10: throws exception when module-declared class does not exist +// --------------------------------------------------------------------------- + +it('throws a clear exception with suggestion when a module-declared class does not exist', function (): void { + $module = makeModuleManifest( + name: 'acme/bad', + source: 'vendor', + globalMiddleware: ['Acme\NonExistent\GhostMiddleware'], + ); + + $resolver = new GlobalMiddlewareResolver(); + + expect(fn () => $resolver->resolve([$module], builtIns: [])) + ->toThrow(ModuleException::class); +}); + +// --------------------------------------------------------------------------- +// Requirement 11: throws exception when array-form entry is missing class key +// --------------------------------------------------------------------------- + +it('throws a clear exception with suggestion when an array-form entry is missing the class key', function (): void { + $module = makeModuleManifest( + name: 'acme/bad', + source: 'vendor', + globalMiddleware: [['priority' => 10]], // missing 'class' key + ); + + $resolver = new GlobalMiddlewareResolver(); + + expect(fn () => $resolver->resolve([$module], builtIns: [])) + ->toThrow(ModuleException::class); +}); + +// --------------------------------------------------------------------------- +// Requirement 12: throws exception when declared class doesn't implement MiddlewareInterface +// --------------------------------------------------------------------------- + +it('throws a clear exception with suggestion when a declared class does not implement MiddlewareInterface', function (): void { + // Create a class that exists but doesn't implement MiddlewareInterface + if (!class_exists('Acme\Mw\NotAMiddleware')) { + eval('namespace Acme\Mw; class NotAMiddleware {}'); + } + + $module = makeModuleManifest( + name: 'acme/bad', + source: 'vendor', + globalMiddleware: ['Acme\Mw\NotAMiddleware'], + ); + + $resolver = new GlobalMiddlewareResolver(); + + expect(fn () => $resolver->resolve([$module], builtIns: [])) + ->toThrow(ModuleException::class); +}); + +// --------------------------------------------------------------------------- +// Requirement 13: built-in entries silently skip when class doesn't exist +// --------------------------------------------------------------------------- + +it('silently skips built-in GLOBAL_MIDDLEWARE entries when the class does not exist (backwards compat)', function (): void { + $builtIns = [ + ['class' => 'Marko\NonExistent\Middleware\FakeMiddleware', 'priority' => 10, 'source' => 'vendor', 'skipIfMissing' => true], + ]; + + $resolver = new GlobalMiddlewareResolver(); + + // Should not throw — just skip the non-existent built-in class + $result = $resolver->resolve([], builtIns: $builtIns); + + expect($result) + ->toBeArray() + ->not->toContain('Marko\NonExistent\Middleware\FakeMiddleware'); +}); + +// --------------------------------------------------------------------------- +// Requirement 14: preserves existing GLOBAL_MIDDLEWARE behavior with no module declarations +// --------------------------------------------------------------------------- + +it('preserves existing GLOBAL_MIDDLEWARE behavior when no modules declare globalMiddleware', function (): void { + // With no modules and default built-ins where classes don't exist → empty result + $resolver = new GlobalMiddlewareResolver(); + + // The DEFAULT_BUILT_INS reference real classes that may not exist in tests. + // This test verifies zero behavior change: passing built-ins with skipIfMissing => true + // when no modules declare anything returns whatever classes actually exist. + $builtIns = GlobalMiddlewareResolver::DEFAULT_BUILT_INS; + $result = $resolver->resolve([], builtIns: $builtIns); + + expect($result)->toBeArray(); + + // Verify the result only contains classes that actually exist + foreach ($result as $class) { + expect(class_exists($class))->toBeTrue("Class $class should exist in result"); + } +}); diff --git a/packages/core/tests/Unit/Module/ModuleDiscoveryTest.php b/packages/core/tests/Unit/Module/ModuleDiscoveryTest.php index 4e4e38b4..adac250f 100644 --- a/packages/core/tests/Unit/Module/ModuleDiscoveryTest.php +++ b/packages/core/tests/Unit/Module/ModuleDiscoveryTest.php @@ -611,3 +611,45 @@ function createTestModule( cleanupDirectory($tempDir); }); + +it('ModuleManifest exposes a globalMiddleware property defaulting to empty array', function (): void { + $manifest = new ModuleManifest( + name: 'acme/test', + version: '1.0.0', + ); + + expect($manifest->globalMiddleware) + ->toBeArray() + ->toBeEmpty(); +}); + +it('ManifestParser reads globalMiddleware from module.php and passes it to ModuleManifest', function (): void { + $tempDir = sys_get_temp_dir() . '/marko-test-' . bin2hex(random_bytes(8)); + mkdir($tempDir, 0755, true); + file_put_contents($tempDir . '/composer.json', json_encode(['name' => 'acme/test'])); + + $modulePhpContent = <<<'PHP' + [ + 'Acme\Middleware\FirstMiddleware', + ['class' => 'Acme\Middleware\SecondMiddleware', 'priority' => 25], + ], +]; +PHP; + file_put_contents($tempDir . '/module.php', $modulePhpContent); + + $parser = new ManifestParser(); + $manifest = $parser->parse($tempDir); + + expect($manifest->globalMiddleware) + ->toBeArray() + ->toHaveCount(2) + ->and($manifest->globalMiddleware[0])->toBe('Acme\Middleware\FirstMiddleware') + ->and($manifest->globalMiddleware[1])->toBe(['class' => 'Acme\Middleware\SecondMiddleware', 'priority' => 25]); + + cleanupDirectory($tempDir); +}); From 332168da6edadcf76599aa061fbfca41692271ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Biarda?= <1135380+michalbiarda@users.noreply.github.com> Date: Wed, 20 May 2026 17:35:08 +0200 Subject: [PATCH 2/3] refactor(core): move built-in middleware declarations to their packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PageCacheMiddleware (priority 10), SessionMiddleware (priority 20), and LayoutMiddleware (priority 30) are now declared via globalMiddleware in their respective module.php files instead of being hardcoded in GlobalMiddlewareResolver::DEFAULT_BUILT_INS. The DEFAULT_BUILT_INS constant and builtIns parameter are removed from GlobalMiddlewareResolver — the resolver now only works with module declarations. Package-level tests verify each middleware's priority. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 28 +++- packages/core/src/Application.php | 9 +- .../src/Module/GlobalMiddlewareResolver.php | 62 +-------- packages/core/tests/Unit/ApplicationTest.php | 27 ---- .../Module/GlobalMiddlewareResolverTest.php | 121 +++++++++--------- packages/layout/module.php | 4 + .../layout/tests/PackageStructureTest.php | 12 ++ packages/page-cache/module.php | 4 + .../page-cache/tests/PackageStructureTest.php | 12 ++ packages/session/module.php | 4 + .../session/tests/PackageStructureTest.php | 12 ++ 11 files changed, 144 insertions(+), 151 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4116f32c..8295a908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,33 @@ Entries from `0.4.0` onward are generated automatically by `bin/release.sh` from ### New Features * feat: add marko/page-cache and marko/page-cache-file packages -* feat(core): add module-declared global middleware support via globalMiddleware key in module.php +* feat(core): add module-declared global middleware support via `globalMiddleware` key in `module.php` + +#### Module-declared global middleware (`marko/core`) + +Any module can now register global HTTP middleware by adding a `'globalMiddleware'` key to its `module.php`: + +```php title="module.php" + [ + // class-string form — default priority 100 + \App\Http\Middleware\MyMiddleware::class, + + // array form — explicit priority (lower = runs earlier) + ['class' => \App\Http\Middleware\EarlyMiddleware::class, 'priority' => 5], + ], +]; +``` + +**Priority semantics** --- entries are sorted by priority value ascending; a lower value runs earlier in the stack. The default priority is `100` when the `priority` key is omitted. Built-in framework middleware (page cache, session, layout) occupy the `10`–`30` range. + +**Deduplication** --- if the same middleware class appears in more than one source (vendor, module, or app), the highest-source-priority entry wins (`app > modules > vendor`). Within the same source, the entry with the lower priority value (runs earlier) is kept. + +**Upgrade note** --- Apps that subclass `Application` and override `discoverGlobalMiddleware()` will not pick up module-declared middleware until they update their override to delegate to `GlobalMiddlewareResolver`. If you have a custom `discoverGlobalMiddleware()`, call `parent::discoverGlobalMiddleware()` or inject and call `GlobalMiddlewareResolver::resolve()` directly. ## [0.5.0] - 2026-05-01 diff --git a/packages/core/src/Application.php b/packages/core/src/Application.php index 8232b887..2765db69 100644 --- a/packages/core/src/Application.php +++ b/packages/core/src/Application.php @@ -345,18 +345,11 @@ public function handleRequest(): void /** * Discover available global middleware classes by merging module-declared - * entries with the built-in hardcoded list, sorted by priority. - * * @return array> * @throws ModuleException When a module-declared middleware class is invalid */ private function discoverGlobalMiddleware(): array { - $resolver = new GlobalMiddlewareResolver(); - - return $resolver->resolve( - modules: $this->modules, - builtIns: GlobalMiddlewareResolver::DEFAULT_BUILT_INS, - ); + return (new GlobalMiddlewareResolver())->resolve($this->modules); } } diff --git a/packages/core/src/Module/GlobalMiddlewareResolver.php b/packages/core/src/Module/GlobalMiddlewareResolver.php index 278fbe72..4adc7e17 100644 --- a/packages/core/src/Module/GlobalMiddlewareResolver.php +++ b/packages/core/src/Module/GlobalMiddlewareResolver.php @@ -8,43 +8,14 @@ use Marko\Routing\Middleware\MiddlewareInterface; /** - * Resolves global HTTP middleware from module declarations and built-in defaults. + * Resolves global HTTP middleware from module declarations. * - * Merges module-declared globalMiddleware entries with built-in hardcoded - * entries, sorts by priority (ascending = runs earlier), and deduplicates - * using source priority (app > modules > vendor). + * Collects globalMiddleware entries from all loaded modules, sorts by priority + * (ascending = runs earlier), and deduplicates using source priority + * (app > modules > vendor). */ class GlobalMiddlewareResolver { - /** - * Default built-in global middleware with their priorities. - * - * These entries use skipIfMissing = true so apps without the optional - * packages (page-cache, session, layout) continue to boot unchanged. - * - * @var array - */ - public const array DEFAULT_BUILT_INS = [ - [ - 'class' => 'Marko\\PageCache\\Middleware\\PageCacheMiddleware', - 'priority' => 10, - 'source' => 'vendor', - 'skipIfMissing' => true, - ], - [ - 'class' => 'Marko\\Session\\Middleware\\SessionMiddleware', - 'priority' => 20, - 'source' => 'vendor', - 'skipIfMissing' => true, - ], - [ - 'class' => 'Marko\\Layout\\Middleware\\LayoutMiddleware', - 'priority' => 30, - 'source' => 'vendor', - 'skipIfMissing' => true, - ], - ]; - private const array SOURCE_PRIORITY = [ 'vendor' => 0, 'modules' => 1, @@ -54,38 +25,19 @@ class GlobalMiddlewareResolver private const int DEFAULT_PRIORITY = 100; /** - * Resolve global middleware from module declarations and built-in entries. + * Resolve global middleware from module declarations. * * @param array $modules - * @param array $builtIns * @return array> * @throws ModuleException When a module-declared class does not exist, is missing the class key, or does not implement MiddlewareInterface */ - public function resolve( - array $modules, - array $builtIns, - ): array { + public function resolve(array $modules): array + { // Candidate map: class => [priority, sourcePriority] // We keep the highest-source-priority entry; within same source, the lowest priority value. /** @var array $candidates */ $candidates = []; - // Process built-in entries first (lowest source priority = vendor) - foreach ($builtIns as $builtIn) { - $class = $builtIn['class']; - $skipIfMissing = $builtIn['skipIfMissing'] ?? false; - - if (!class_exists($class)) { - if ($skipIfMissing) { - continue; - } - } - - $sourcePriority = self::SOURCE_PRIORITY[$builtIn['source']] ?? 0; - $this->addCandidate($candidates, $class, $builtIn['priority'], $sourcePriority); - } - - // Process module-declared entries foreach ($modules as $module) { $sourcePriority = self::SOURCE_PRIORITY[$module->source] ?? 0; diff --git a/packages/core/tests/Unit/ApplicationTest.php b/packages/core/tests/Unit/ApplicationTest.php index c8f2e023..4dc0ea3e 100644 --- a/packages/core/tests/Unit/ApplicationTest.php +++ b/packages/core/tests/Unit/ApplicationTest.php @@ -2005,30 +2005,3 @@ public function run(string \$result): string appTestCleanupDirectory($baseDir); }); -it('includes SessionMiddleware in global middleware built-ins', function (): void { - $classes = array_column(Marko\Core\Module\GlobalMiddlewareResolver::DEFAULT_BUILT_INS, 'class'); - - expect($classes)->toContain('Marko\\Session\\Middleware\\SessionMiddleware'); -}); - -it('includes LayoutMiddleware in global middleware built-ins', function (): void { - $classes = array_column(Marko\Core\Module\GlobalMiddlewareResolver::DEFAULT_BUILT_INS, 'class'); - - expect($classes)->toContain('Marko\\Layout\\Middleware\\LayoutMiddleware'); -}); - -it('includes PageCacheMiddleware in global middleware built-ins', function (): void { - $classes = array_column(Marko\Core\Module\GlobalMiddlewareResolver::DEFAULT_BUILT_INS, 'class'); - - expect($classes)->toContain('Marko\\PageCache\\Middleware\\PageCacheMiddleware'); -}); - -it('lists PageCacheMiddleware before SessionMiddleware in global middleware order', function (): void { - $builtIns = Marko\Core\Module\GlobalMiddlewareResolver::DEFAULT_BUILT_INS; - $classes = array_column($builtIns, 'class'); - - $pageCache = array_search('Marko\\PageCache\\Middleware\\PageCacheMiddleware', $classes); - $session = array_search('Marko\\Session\\Middleware\\SessionMiddleware', $classes); - - expect($pageCache)->toBeLessThan($session); -}); diff --git a/packages/core/tests/Unit/Module/GlobalMiddlewareResolverTest.php b/packages/core/tests/Unit/Module/GlobalMiddlewareResolverTest.php index e0650d00..53f9210c 100644 --- a/packages/core/tests/Unit/Module/GlobalMiddlewareResolverTest.php +++ b/packages/core/tests/Unit/Module/GlobalMiddlewareResolverTest.php @@ -60,7 +60,7 @@ function makeModuleManifest( ); $resolver = new GlobalMiddlewareResolver(); - $result = $resolver->resolve([$module], builtIns: []); + $result = $resolver->resolve([$module]); expect($result) ->toContain('Acme\Mw\AlphaMiddleware') @@ -83,7 +83,7 @@ function makeModuleManifest( ); $resolver = new GlobalMiddlewareResolver(); - $result = $resolver->resolve([$module], builtIns: []); + $result = $resolver->resolve([$module]); expect($result)->toContain('Acme\Mw\GammaMiddleware'); }); @@ -108,7 +108,7 @@ function makeModuleManifest( ); $resolver = new GlobalMiddlewareResolver(); - $result = $resolver->resolve([$module], builtIns: []); + $result = $resolver->resolve([$module]); $deltaIndex = array_search('Acme\Mw\DeltaMiddleware', $result); $epsilonIndex = array_search('Acme\Mw\EpsilonMiddleware', $result); @@ -118,34 +118,31 @@ function makeModuleManifest( }); // --------------------------------------------------------------------------- -// Requirement 4: merges with built-in hardcoded list +// Requirement 4: merges globalMiddleware from multiple modules // --------------------------------------------------------------------------- -it('merges module-declared globalMiddleware with built-in hardcoded list', function (): void { +it('merges globalMiddleware declarations from multiple modules', function (): void { makeMiddlewareClass('Acme\Mw\ZetaMiddleware'); + makeMiddlewareClass('Acme\Mw\OmegaMiddleware'); - $module = makeModuleManifest( - name: 'acme/test', + $moduleA = makeModuleManifest( + name: 'acme/mod-a', source: 'vendor', globalMiddleware: ['Acme\Mw\ZetaMiddleware'], ); - $builtIns = [ - ['class' => 'Acme\Mw\ZetaMiddleware', 'priority' => 10, 'source' => 'vendor'], // same class won't duplicate - ]; - - // Use different built-in so we can verify both appear - makeMiddlewareClass('Acme\BuiltIn\BuiltInMiddleware'); - $builtInsClean = [ - ['class' => 'Acme\BuiltIn\BuiltInMiddleware', 'priority' => 10, 'source' => 'vendor'], - ]; + $moduleB = makeModuleManifest( + name: 'acme/mod-b', + source: 'vendor', + globalMiddleware: ['Acme\Mw\OmegaMiddleware'], + ); $resolver = new GlobalMiddlewareResolver(); - $result = $resolver->resolve([$module], builtIns: $builtInsClean); + $result = $resolver->resolve([$moduleA, $moduleB]); expect($result) ->toContain('Acme\Mw\ZetaMiddleware') - ->toContain('Acme\BuiltIn\BuiltInMiddleware'); + ->toContain('Acme\Mw\OmegaMiddleware'); }); // --------------------------------------------------------------------------- @@ -154,11 +151,11 @@ function makeModuleManifest( it('sorts merged globalMiddleware by priority ascending', function (): void { makeMiddlewareClass('Acme\Mw\EtaMiddleware'); // priority 30 - makeMiddlewareClass('Acme\Mw\ThetaMiddleware'); // priority 10 (built-in) + makeMiddlewareClass('Acme\Mw\ThetaMiddleware'); // priority 10 makeMiddlewareClass('Acme\Mw\IotaMiddleware'); // priority 20 - $module = makeModuleManifest( - name: 'acme/test', + $moduleA = makeModuleManifest( + name: 'acme/mod-a', source: 'vendor', globalMiddleware: [ ['class' => 'Acme\Mw\EtaMiddleware', 'priority' => 30], @@ -166,12 +163,16 @@ function makeModuleManifest( ], ); - $builtIns = [ - ['class' => 'Acme\Mw\ThetaMiddleware', 'priority' => 10, 'source' => 'vendor'], - ]; + $moduleB = makeModuleManifest( + name: 'acme/mod-b', + source: 'vendor', + globalMiddleware: [ + ['class' => 'Acme\Mw\ThetaMiddleware', 'priority' => 10], + ], + ); $resolver = new GlobalMiddlewareResolver(); - $result = $resolver->resolve([$module], builtIns: $builtIns); + $result = $resolver->resolve([$moduleA, $moduleB]); $thetaIndex = array_search('Acme\Mw\ThetaMiddleware', $result); $iotaIndex = array_search('Acme\Mw\IotaMiddleware', $result); @@ -205,7 +206,7 @@ function makeModuleManifest( ); $resolver = new GlobalMiddlewareResolver(); - $result = $resolver->resolve([$vendorModule, $appModule], builtIns: []); + $result = $resolver->resolve([$vendorModule, $appModule]); // Should appear only once — app source wins $count = count(array_filter($result, fn ($c) => $c === 'Acme\Mw\KappaMiddleware')); @@ -240,7 +241,7 @@ function makeModuleManifest( ); $resolver = new GlobalMiddlewareResolver(); - $result = $resolver->resolve([$module1, $module2], builtIns: []); + $result = $resolver->resolve([$module1, $module2]); // LambdaMiddleware should appear once, with priority 40 (comes before MuMiddleware priority 5? no) $count = count(array_filter($result, fn ($c) => $c === 'Acme\Mw\LambdaMiddleware')); @@ -257,14 +258,31 @@ function makeModuleManifest( // Requirement 8: built-in priorities // --------------------------------------------------------------------------- -it('assigns priority 10 to PageCacheMiddleware 20 to SessionMiddleware 30 to LayoutMiddleware as built-in defaults', function (): void { - // Make built-in classes exist for this test +it('assigns priority 10 to PageCacheMiddleware 20 to SessionMiddleware 30 to LayoutMiddleware as module declarations', function (): void { makeMiddlewareClass('Marko\PageCache\Middleware\PageCacheMiddleware'); makeMiddlewareClass('Marko\Session\Middleware\SessionMiddleware'); makeMiddlewareClass('Marko\Layout\Middleware\LayoutMiddleware'); + $pageCacheModule = makeModuleManifest( + name: 'marko/page-cache', + source: 'vendor', + globalMiddleware: [['class' => 'Marko\PageCache\Middleware\PageCacheMiddleware', 'priority' => 10]], + ); + + $sessionModule = makeModuleManifest( + name: 'marko/session', + source: 'vendor', + globalMiddleware: [['class' => 'Marko\Session\Middleware\SessionMiddleware', 'priority' => 20]], + ); + + $layoutModule = makeModuleManifest( + name: 'marko/layout', + source: 'vendor', + globalMiddleware: [['class' => 'Marko\Layout\Middleware\LayoutMiddleware', 'priority' => 30]], + ); + $resolver = new GlobalMiddlewareResolver(); - $result = $resolver->resolve([], builtIns: GlobalMiddlewareResolver::DEFAULT_BUILT_INS); + $result = $resolver->resolve([$pageCacheModule, $sessionModule, $layoutModule]); $pageCacheIndex = array_search('Marko\PageCache\Middleware\PageCacheMiddleware', $result); $sessionIndex = array_search('Marko\Session\Middleware\SessionMiddleware', $result); @@ -296,7 +314,7 @@ function makeModuleManifest( ); $resolver = new GlobalMiddlewareResolver(); - $result = $resolver->resolve([$module], builtIns: []); + $result = $resolver->resolve([$module]); expect($result)->toBeArray(); foreach ($result as $entry) { @@ -323,7 +341,7 @@ function makeModuleManifest( $resolver = new GlobalMiddlewareResolver(); - expect(fn () => $resolver->resolve([$module], builtIns: [])) + expect(fn () => $resolver->resolve([$module])) ->toThrow(ModuleException::class); }); @@ -340,7 +358,7 @@ function makeModuleManifest( $resolver = new GlobalMiddlewareResolver(); - expect(fn () => $resolver->resolve([$module], builtIns: [])) + expect(fn () => $resolver->resolve([$module])) ->toThrow(ModuleException::class); }); @@ -362,47 +380,30 @@ function makeModuleManifest( $resolver = new GlobalMiddlewareResolver(); - expect(fn () => $resolver->resolve([$module], builtIns: [])) + expect(fn () => $resolver->resolve([$module])) ->toThrow(ModuleException::class); }); // --------------------------------------------------------------------------- -// Requirement 13: built-in entries silently skip when class doesn't exist +// Requirement 13: returns empty array when modules have no globalMiddleware // --------------------------------------------------------------------------- -it('silently skips built-in GLOBAL_MIDDLEWARE entries when the class does not exist (backwards compat)', function (): void { - $builtIns = [ - ['class' => 'Marko\NonExistent\Middleware\FakeMiddleware', 'priority' => 10, 'source' => 'vendor', 'skipIfMissing' => true], - ]; +it('returns empty array when modules exist but none declare globalMiddleware', function (): void { + $module = makeModuleManifest(name: 'acme/no-mw', source: 'vendor'); $resolver = new GlobalMiddlewareResolver(); + $result = $resolver->resolve([$module]); - // Should not throw — just skip the non-existent built-in class - $result = $resolver->resolve([], builtIns: $builtIns); - - expect($result) - ->toBeArray() - ->not->toContain('Marko\NonExistent\Middleware\FakeMiddleware'); + expect($result)->toBe([]); }); // --------------------------------------------------------------------------- -// Requirement 14: preserves existing GLOBAL_MIDDLEWARE behavior with no module declarations +// Requirement 14: returns empty array when no modules are loaded // --------------------------------------------------------------------------- -it('preserves existing GLOBAL_MIDDLEWARE behavior when no modules declare globalMiddleware', function (): void { - // With no modules and default built-ins where classes don't exist → empty result +it('returns empty array when no modules are loaded', function (): void { $resolver = new GlobalMiddlewareResolver(); + $result = $resolver->resolve([]); - // The DEFAULT_BUILT_INS reference real classes that may not exist in tests. - // This test verifies zero behavior change: passing built-ins with skipIfMissing => true - // when no modules declare anything returns whatever classes actually exist. - $builtIns = GlobalMiddlewareResolver::DEFAULT_BUILT_INS; - $result = $resolver->resolve([], builtIns: $builtIns); - - expect($result)->toBeArray(); - - // Verify the result only contains classes that actually exist - foreach ($result as $class) { - expect(class_exists($class))->toBeTrue("Class $class should exist in result"); - } + expect($result)->toBe([]); }); diff --git a/packages/layout/module.php b/packages/layout/module.php index 2e2e0443..62a1d474 100644 --- a/packages/layout/module.php +++ b/packages/layout/module.php @@ -8,6 +8,7 @@ use Marko\Layout\LayoutProcessor; use Marko\Layout\LayoutProcessorInterface; use Marko\Layout\LayoutResolver; +use Marko\Layout\Middleware\LayoutMiddleware; return [ 'bindings' => [ @@ -18,4 +19,7 @@ HandleResolver::class => HandleResolver::class, LayoutResolver::class => LayoutResolver::class, ], + 'globalMiddleware' => [ + ['class' => LayoutMiddleware::class, 'priority' => 30], + ], ]; diff --git a/packages/layout/tests/PackageStructureTest.php b/packages/layout/tests/PackageStructureTest.php index 315514ee..1f736f8a 100644 --- a/packages/layout/tests/PackageStructureTest.php +++ b/packages/layout/tests/PackageStructureTest.php @@ -65,3 +65,15 @@ expect($config)->toBeArray(); }); + +it('module.php declares LayoutMiddleware as globalMiddleware at priority 30', function (): void { + $module = require dirname(__DIR__) . '/module.php'; + + $entry = array_find( + $module['globalMiddleware'] ?? [], + fn (array $e) => ($e['class'] ?? '') === 'Marko\\Layout\\Middleware\\LayoutMiddleware', + ); + + expect($entry)->not->toBeNull() + ->and($entry['priority'])->toBe(30); +}); diff --git a/packages/page-cache/module.php b/packages/page-cache/module.php index 068e791a..22e36f14 100644 --- a/packages/page-cache/module.php +++ b/packages/page-cache/module.php @@ -4,6 +4,7 @@ use Marko\Core\Module\ModuleRepositoryInterface; use Marko\PageCache\Boot\IdentityBridgeValidator; +use Marko\PageCache\Middleware\PageCacheMiddleware; // Marko-specific configuration for this module. // Name and version come from composer.json. @@ -15,4 +16,7 @@ ): void { $validator->validate($modules->all()); }, + 'globalMiddleware' => [ + ['class' => PageCacheMiddleware::class, 'priority' => 10], + ], ]; diff --git a/packages/page-cache/tests/PackageStructureTest.php b/packages/page-cache/tests/PackageStructureTest.php index 44a24ae8..1c4a299c 100644 --- a/packages/page-cache/tests/PackageStructureTest.php +++ b/packages/page-cache/tests/PackageStructureTest.php @@ -55,3 +55,15 @@ expect($composer['require'])->toHaveKey('marko/page-cache') ->and($composer['require']['marko/page-cache'])->toBe('self.version'); }); + +it('module.php declares PageCacheMiddleware as globalMiddleware at priority 10', function (): void { + $module = require dirname(__DIR__) . '/module.php'; + + $entry = array_find( + $module['globalMiddleware'] ?? [], + fn (array $e) => ($e['class'] ?? '') === 'Marko\\PageCache\\Middleware\\PageCacheMiddleware', + ); + + expect($entry)->not->toBeNull() + ->and($entry['priority'])->toBe(10); +}); diff --git a/packages/session/module.php b/packages/session/module.php index 2f82225a..52fe6fed 100644 --- a/packages/session/module.php +++ b/packages/session/module.php @@ -3,10 +3,14 @@ declare(strict_types=1); use Marko\Session\Contracts\SessionInterface; +use Marko\Session\Middleware\SessionMiddleware; use Marko\Session\Session; return [ 'singletons' => [ SessionInterface::class => Session::class, ], + 'globalMiddleware' => [ + ['class' => SessionMiddleware::class, 'priority' => 20], + ], ]; diff --git a/packages/session/tests/PackageStructureTest.php b/packages/session/tests/PackageStructureTest.php index 44bd693e..a0fb9ea4 100644 --- a/packages/session/tests/PackageStructureTest.php +++ b/packages/session/tests/PackageStructureTest.php @@ -97,3 +97,15 @@ ->and($config)->toHaveKey('lifetime') ->and($config)->toHaveKey('cookie'); }); + +it('module.php declares SessionMiddleware as globalMiddleware at priority 20', function (): void { + $module = require dirname(__DIR__) . '/module.php'; + + $entry = array_find( + $module['globalMiddleware'] ?? [], + fn (array $e) => ($e['class'] ?? '') === 'Marko\\Session\\Middleware\\SessionMiddleware', + ); + + expect($entry)->not->toBeNull() + ->and($entry['priority'])->toBe(20); +}); From 9ec897cbe16c2209d687f57d7dac89708876a2cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Biarda?= <1135380+michalbiarda@users.noreply.github.com> Date: Thu, 21 May 2026 16:03:42 +0200 Subject: [PATCH 3/3] fix(core): preserve globalMiddleware when copying ModuleManifest in discovery withPathAndSource() was silently dropping the globalMiddleware field, causing all module-declared global middleware to never be registered. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/Module/ModuleDiscovery.php | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/Module/ModuleDiscovery.php b/packages/core/src/Module/ModuleDiscovery.php index 48e75f5b..dce412c1 100644 --- a/packages/core/src/Module/ModuleDiscovery.php +++ b/packages/core/src/Module/ModuleDiscovery.php @@ -168,6 +168,7 @@ private function withPathAndSource( source: $source, autoload: $manifest->autoload, boot: $manifest->boot, + globalMiddleware: $manifest->globalMiddleware, ); }