Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +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`

#### 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"
<?php

declare(strict_types=1);

return [
'globalMiddleware' => [
// 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

Expand Down
20 changes: 4 additions & 16 deletions packages/core/src/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -348,20 +344,12 @@ public function handleRequest(): void
}

/**
* Discover available global middleware classes.
*
* Discover available global middleware classes by merging module-declared
* @return array<class-string<MiddlewareInterface>>
* @throws ModuleException When a module-declared middleware class is invalid
*/
private function discoverGlobalMiddleware(): array
{
$middleware = [];

foreach (self::GLOBAL_MIDDLEWARE as $class) {
if (class_exists($class)) {
$middleware[] = $class;
}
}

return $middleware;
return (new GlobalMiddlewareResolver())->resolve($this->modules);
}
}
23 changes: 23 additions & 0 deletions packages/core/src/Exceptions/ModuleException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]",
);
}
}
131 changes: 131 additions & 0 deletions packages/core/src/Module/GlobalMiddlewareResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

declare(strict_types=1);

namespace Marko\Core\Module;

use Marko\Core\Exceptions\ModuleException;
use Marko\Routing\Middleware\MiddlewareInterface;

/**
* Resolves global HTTP middleware from module declarations.
*
* Collects globalMiddleware entries from all loaded modules, sorts by priority
* (ascending = runs earlier), and deduplicates using source priority
* (app > modules > vendor).
*/
class GlobalMiddlewareResolver
{
private const array SOURCE_PRIORITY = [
'vendor' => 0,
'modules' => 1,
'app' => 2,
];

private const int DEFAULT_PRIORITY = 100;

/**
* Resolve global middleware from module declarations.
*
* @param array<ModuleManifest> $modules
* @return array<class-string<MiddlewareInterface>>
* @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
{
// Candidate map: class => [priority, sourcePriority]
// We keep the highest-source-priority entry; within same source, the lowest priority value.
/** @var array<string, array{priority: int, sourcePriority: int}> $candidates */
$candidates = [];

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<string, array{priority: int, sourcePriority: int}> $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;
}
}
}
1 change: 1 addition & 0 deletions packages/core/src/Module/ManifestParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public function parse(
singletons: $moduleData['singletons'] ?? [],
autoload: $composerData['autoload']['psr-4'] ?? [],
boot: $moduleData['boot'] ?? null,
globalMiddleware: $moduleData['globalMiddleware'] ?? [],
);
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/Module/ModuleDiscovery.php
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ private function withPathAndSource(
source: $source,
autoload: $manifest->autoload,
boot: $manifest->boot,
globalMiddleware: $manifest->globalMiddleware,
);
}

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/Module/ModuleManifest.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
* @param string $source Discovery source: vendor, modules, or app
* @param array<string, string> $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<int, string|array{class: string, priority?: int}> $globalMiddleware Global HTTP middleware declared by this module (from module.php)
*/
public function __construct(
public string $name,
Expand All @@ -41,5 +42,6 @@ public function __construct(
public string $source = '',
public array $autoload = [],
public ?Closure $boot = null,
public array $globalMiddleware = [],
) {}
}
31 changes: 0 additions & 31 deletions packages/core/tests/Unit/ApplicationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2005,34 +2005,3 @@ 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');

expect($constant->getValue())->toContain('Marko\\Session\\Middleware\\SessionMiddleware');
});

it('includes LayoutMiddleware in global middleware', function (): void {
$reflection = new ReflectionClass(Application::class);
$constant = $reflection->getReflectionConstant('GLOBAL_MIDDLEWARE');

expect($constant->getValue())->toContain('Marko\\Layout\\Middleware\\LayoutMiddleware');
});

it('includes PageCacheMiddleware in global middleware', function (): void {
$reflection = new ReflectionClass(Application::class);
$constant = $reflection->getReflectionConstant('GLOBAL_MIDDLEWARE');

expect($constant->getValue())->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();

$pageCache = array_search('Marko\\PageCache\\Middleware\\PageCacheMiddleware', $middleware);
$session = array_search('Marko\\Session\\Middleware\\SessionMiddleware', $middleware);

expect($pageCache)->toBeLessThan($session);
});
Loading