From 33eadb6cdb2ec86c389a7288a41591c3aeae30ff Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 8 Oct 2026 14:02:07 +0200 Subject: [PATCH 1/9] ci: run the test suite on pull requests to release branches --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae3825ed..2fc93bbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,8 @@ on: push: branches: [main, develop, 'feature/**', 'hotfix/**', 'release/**'] pull_request: - branches: [main, develop] + # release/** too: features of an upcoming release are merged into its branch + branches: [main, develop, 'release/**'] # Nightly, on the default branch: the browser tests in Firefox and WebKit as well as # Chromium, which pull requests run alone to stay fast. schedule: From 5e3103a4a2f1f8c3cb274a1d07294a3f8ba1a400 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 8 Oct 2026 15:00:25 +0200 Subject: [PATCH 2/9] feat(route): remember which template the hierarchy answered with --- .../Services/AnsweringTemplate.php | 37 +++++++++ src/Route/Domain/Enums/TemplateOutcome.php | 20 +++++ .../Domain/Models/TemplateResolution.php | 33 ++++++++ .../Providers/RouteServiceProvider.php | 5 ++ .../Http/Controllers/FrontendController.php | 39 ++++++++- .../Controllers/FrontendControllerTest.php | 82 +++++++++++++++++++ 6 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 src/Route/Application/Services/AnsweringTemplate.php create mode 100644 src/Route/Domain/Enums/TemplateOutcome.php create mode 100644 src/Route/Domain/Models/TemplateResolution.php diff --git a/src/Route/Application/Services/AnsweringTemplate.php b/src/Route/Application/Services/AnsweringTemplate.php new file mode 100644 index 00000000..7de573c4 --- /dev/null +++ b/src/Route/Application/Services/AnsweringTemplate.php @@ -0,0 +1,37 @@ +resolution = $resolution; + } + + public function resolution(): ?TemplateResolution + { + return $this->resolution; + } + + /** + * Forget the last resolution, for workers that serve several requests. + */ + public function reset(): void + { + $this->resolution = null; + } +} diff --git a/src/Route/Domain/Enums/TemplateOutcome.php b/src/Route/Domain/Enums/TemplateOutcome.php new file mode 100644 index 00000000..88648ec4 --- /dev/null +++ b/src/Route/Domain/Enums/TemplateOutcome.php @@ -0,0 +1,20 @@ +app->singleton(AnsweringTemplate::class); + // Register the WordPress type resolver $this->app->singleton(WordPressTypeResolverInterface::class, WordPressTypeResolver::class); diff --git a/src/Route/UI/Http/Controllers/FrontendController.php b/src/Route/UI/Http/Controllers/FrontendController.php index f6e16cea..45b68fe2 100644 --- a/src/Route/UI/Http/Controllers/FrontendController.php +++ b/src/Route/UI/Http/Controllers/FrontendController.php @@ -8,6 +8,9 @@ use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\View; +use Pollora\Route\Application\Services\AnsweringTemplate; +use Pollora\Route\Domain\Enums\TemplateOutcome; +use Pollora\Route\Domain\Models\TemplateResolution; use Pollora\View\Domain\Contracts\TemplateFinderInterface; /** @@ -30,8 +33,15 @@ class FrontendController */ private bool $usedIndexFallback = false; + /** + * The conditional tag whose template getter found the template, null when + * the index fallback answered. + */ + private ?string $matchedCondition = null; + public function __construct( - private readonly TemplateFinderInterface $templateFinder + private readonly TemplateFinderInterface $templateFinder, + private readonly AnsweringTemplate $answeringTemplate = new AnsweringTemplate, ) {} /** @@ -41,6 +51,8 @@ public function handle(Request $request): Response { // Early return if themes are not being used if (function_exists('wp_using_themes') && ! wp_using_themes()) { + $this->remember('', null, TemplateOutcome::NotFound); + return $this->renderNotFound(); } @@ -54,14 +66,20 @@ public function handle(Request $request): Response // A real index.blade.php IS a valid fallback for other request types, // but for 404s it means the theme has no 404 handling at all. if (is_404() && $this->usedIndexFallback) { + $this->remember($templatePath, $viewName, TemplateOutcome::NotFound); + return $this->renderNotFound(); } if ($viewName && View::exists($viewName)) { + $this->remember($templatePath, $viewName, TemplateOutcome::View); + return response(View::make($viewName), is_404() ? Response::HTTP_NOT_FOUND : Response::HTTP_OK); } if (file_exists($templatePath) && $this->isAllowedTemplatePath($templatePath)) { + $this->remember($templatePath, null, TemplateOutcome::File); + ob_start(); include $templatePath; $content = ob_get_clean(); @@ -75,9 +93,25 @@ public function handle(Request $request): Response } // No WordPress template found — fall back to Laravel's error view + $this->remember($templatePath, $viewName, TemplateOutcome::NotFound); + return $this->renderNotFound(); } + /** + * Keep what the hierarchy settled on, for debugging tools. + */ + private function remember(string $templatePath, ?string $viewName, TemplateOutcome $outcome): void + { + $this->answeringTemplate->record(new TemplateResolution( + template: $templatePath, + condition: $this->matchedCondition, + view: $viewName ?: null, + usedIndexFallback: $this->usedIndexFallback, + outcome: $outcome, + )); + } + /** * Render a 404 response using Laravel's error views. * @@ -129,6 +163,7 @@ private function isAllowedTemplatePath(string $templatePath): bool protected function getTemplateFile(): string { $this->usedIndexFallback = false; + $this->matchedCondition = null; if (wp_using_themes()) { @@ -160,6 +195,8 @@ protected function getTemplateFile(): string } if ($template) { + $this->matchedCondition = $tag; + if ($tag === 'is_attachment') { remove_filter('the_content', 'prepend_attachment'); } diff --git a/tests/Feature/Route/UI/Http/Controllers/FrontendControllerTest.php b/tests/Feature/Route/UI/Http/Controllers/FrontendControllerTest.php index ecf67288..a2b41b4f 100644 --- a/tests/Feature/Route/UI/Http/Controllers/FrontendControllerTest.php +++ b/tests/Feature/Route/UI/Http/Controllers/FrontendControllerTest.php @@ -5,6 +5,8 @@ use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\View; +use Pollora\Route\Application\Services\AnsweringTemplate; +use Pollora\Route\Domain\Enums\TemplateOutcome; use Pollora\Route\UI\Http\Controllers\FrontendController; use Pollora\View\Domain\Contracts\TemplateFinderInterface; @@ -206,4 +208,84 @@ expect($response->getStatusCode())->toBe(404); expect($response->getContent())->toBe('Not Found'); }); + + it('remembers the condition and the Blade view that answered', function (): void { + $answering = new AnsweringTemplate; + $controller = new FrontendController($this->templateFinder, $answering); + + Brain\Monkey\Functions\when('wp_using_themes')->justReturn(true); + Brain\Monkey\Functions\stubs([ + 'is_embed' => false, + 'is_404' => false, + 'is_search' => false, + 'is_front_page' => false, + 'is_home' => false, + 'is_privacy_policy' => false, + 'is_post_type_archive' => false, + 'is_tax' => false, + 'is_attachment' => false, + 'is_single' => true, + ]); + Brain\Monkey\Functions\when('get_single_template')->justReturn('/theme/single.blade.php'); + Brain\Monkey\Functions\when('apply_filters')->alias(fn ($filter, $value) => $value); + + $this->templateFinder->shouldReceive('getViewNameFromPath')->andReturn('single'); + View::shouldReceive('exists')->with('single')->andReturn(true); + View::shouldReceive('make')->with('single')->andReturn('single'); + + $controller->handle(Request::create('/hello-world')); + + $resolution = $answering->resolution(); + + expect($resolution)->not->toBeNull() + ->and($resolution->template)->toBe('/theme/single.blade.php') + ->and($resolution->condition)->toBe('is_single') + ->and($resolution->view)->toBe('single') + ->and($resolution->usedIndexFallback)->toBeFalse() + ->and($resolution->outcome)->toBe(TemplateOutcome::View); + }); + + it('remembers a 404 that only the index could answer as not found', function (): void { + $answering = new AnsweringTemplate; + $controller = new FrontendController($this->templateFinder, $answering); + + Brain\Monkey\Functions\when('wp_using_themes')->justReturn(true); + Brain\Monkey\Functions\stubs([ + 'is_embed' => false, + 'is_404' => true, + 'is_search' => false, + 'is_front_page' => false, + 'is_home' => false, + 'is_privacy_policy' => false, + 'is_post_type_archive' => false, + 'is_tax' => false, + 'is_attachment' => false, + 'is_single' => false, + 'is_page' => false, + 'is_singular' => false, + 'is_category' => false, + 'is_tag' => false, + 'is_author' => false, + 'is_date' => false, + 'is_archive' => false, + ]); + Brain\Monkey\Functions\when('get_404_template')->justReturn(''); + Brain\Monkey\Functions\when('get_index_template')->justReturn('/theme/index.php'); + Brain\Monkey\Functions\when('apply_filters')->alias(fn ($filter, $value) => $value); + + $this->templateFinder->shouldReceive('getViewNameFromPath')->andReturn('index'); + View::shouldReceive('exists')->andReturn(false); + View::shouldReceive('replaceNamespace')->andReturnNull(); + View::shouldReceive('addNamespace')->andReturnNull(); + + $controller->handle(Request::create('/nothing-here')); + + expect($answering->resolution()?->condition)->toBeNull() + ->and($answering->resolution()?->usedIndexFallback)->toBeTrue() + ->and($answering->resolution()?->outcome)->toBe(TemplateOutcome::NotFound); + }); + + it('leaves nothing behind for requests the hierarchy never saw', function (): void { + expect((new AnsweringTemplate)->resolution())->toBeNull(); + }); }); From 46b4797943695bd620a21d19add534c0b50ba2ab Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 8 Oct 2026 15:00:25 +0200 Subject: [PATCH 3/9] feat(wordpress): announce WordPress before its settings file loads --- src/WordPress/Bootstrap.php | 7 +++- src/WordPress/Events/WordPressBooting.php | 27 +++++++++++++ .../WordPress/WordPressBootingEventTest.php | 39 +++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 src/WordPress/Events/WordPressBooting.php create mode 100644 tests/Feature/WordPress/WordPressBootingEventTest.php diff --git a/src/WordPress/Bootstrap.php b/src/WordPress/Bootstrap.php index 8129107d..80f908e2 100644 --- a/src/WordPress/Bootstrap.php +++ b/src/WordPress/Bootstrap.php @@ -13,6 +13,7 @@ use Pollora\Hook\Domain\Contract\Action; use Pollora\Support\Facades\Constant; use Pollora\Support\WordPress; +use Pollora\WordPress\Events\WordPressBooting; class Bootstrap { @@ -153,10 +154,14 @@ private function loadWordPressSettings(): void } // Apply lightweight mode filters before WordPress loads - if ($this->isLightweightRequest()) { + $lightweight = $this->isLightweightRequest(); + + if ($lightweight) { $this->applyLightweightFilters(); } + event(new WordPressBooting($lightweight)); + if (! $this->consoleDetectionService->isWpCli()) { require_once ABSPATH.'wp-settings.php'; } diff --git a/src/WordPress/Events/WordPressBooting.php b/src/WordPress/Events/WordPressBooting.php new file mode 100644 index 00000000..bca0fdfe --- /dev/null +++ b/src/WordPress/Events/WordPressBooting.php @@ -0,0 +1,27 @@ +shouldReceive('isConsole')->andReturn(false); + $console->shouldReceive('isWpCli')->andReturn(true); + + $bootstrap = new Bootstrap( + $console, + Mockery::mock(DebugDetectorInterface::class), + Mockery::mock(Action::class), + ); + + $database = new ReflectionProperty(Bootstrap::class, 'db'); + $database->setValue($bootstrap, ['prefix' => 'wp_']); + + (new ReflectionMethod(Bootstrap::class, 'loadWordPressSettings'))->invoke($bootstrap); + + Event::assertDispatched( + WordPressBooting::class, + fn (WordPressBooting $event): bool => $event->lightweight === false, + ); +}); From f18136501c4286cb911f0cf670fbb285f489b39d Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 8 Oct 2026 15:00:25 +0200 Subject: [PATCH 4/9] feat(discovery): record where each location's structures came from --- .../Services/DiscoveryCacheManager.php | 41 +++++++++++++++++++ .../DiscoveryCacheManagerSlowScanTest.php | 41 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/src/Discovery/Infrastructure/Services/DiscoveryCacheManager.php b/src/Discovery/Infrastructure/Services/DiscoveryCacheManager.php index d2dfcd69..d1e98790 100644 --- a/src/Discovery/Infrastructure/Services/DiscoveryCacheManager.php +++ b/src/Discovery/Infrastructure/Services/DiscoveryCacheManager.php @@ -40,6 +40,13 @@ class DiscoveryCacheManager */ private readonly ?DiscoverCacheDriver $cacheDriver; + /** + * Where each location's structures came from in this process, in order. + * + * @var list + */ + private array $scans = []; + public function __construct( private readonly Container $container, private readonly DebugDetectorInterface $debugDetector @@ -60,12 +67,17 @@ public function getStructuresForLocation(DiscoveryLocationInterface $location, D if (isset(self::$structuresCache[$cacheId])) { $context->recordCacheHit(); + $this->recordScan($location, 'memory', 0.0, count(self::$structuresCache[$cacheId])); return self::$structuresCache[$cacheId]; } $context->recordCacheMiss(); + // Asked before reading: once the discoverer has run, a cold cache has + // been filled and looks exactly like a warm one. + $fromCache = $this->shouldUseCache() && $this->cacheDriver->has($cacheId); + $discover = $this->createSpatieDiscoverer($location, $cacheId); $startedAt = microtime(true); @@ -73,12 +85,41 @@ public function getStructuresForLocation(DiscoveryLocationInterface $location, D $elapsed = (microtime(true) - $startedAt) * 1000; self::$structuresCache[$cacheId] = $structures; + $this->recordScan($location, $fromCache ? 'cache' : 'disk', $elapsed, count($structures)); $this->warnIfSlow($location, $elapsed, count($structures)); return $structures; } + /** + * Where each location's structures came from, and what it cost. + * + * `memory` is this process's own copy, `cache` the persistent cache, `disk` + * a real scan — the one that costs, and the only one in debug mode, where + * the persistent cache is off. The context's hit and miss counters only + * know about the first of the three. + * + * @return list + */ + public function scans(): array + { + return $this->scans; + } + + /** + * @param 'memory'|'cache'|'disk' $source + */ + private function recordScan(DiscoveryLocationInterface $location, string $source, float $milliseconds, int $structures): void + { + $this->scans[] = [ + 'path' => $location->getPath(), + 'source' => $source, + 'milliseconds' => round($milliseconds, 2), + 'structures' => $structures, + ]; + } + /** * Say so when one location costs more than it can possibly be worth. * diff --git a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php index c06cfd23..1266b4ed 100644 --- a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php +++ b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php @@ -10,6 +10,7 @@ use Pollora\Discovery\Infrastructure\Services\DiscoveryCacheManager; use Pollora\Discovery\Infrastructure\Services\ReflectionCache; use Psr\Log\LoggerInterface; +use Spatie\StructureDiscoverer\Cache\StaticDiscoverCacheDriver; /** * A location that costs seconds has to say so. @@ -130,3 +131,43 @@ function scanOf(DiscoveryCacheManager $manager, string $path): void ->invoke($manager, locationAt('/srv/site/plugins/demo'), 5000.0, 1); })->throwsNoExceptions(); }); + +describe('scan sources', function (): void { + it("tells a real scan from this process's own copy", function (): void { + $manager = managerFor(debug: true, logger: Mockery::spy(LoggerInterface::class)); + + scanOf($manager, $this->dir); + scanOf($manager, $this->dir); + + $scans = $manager->scans(); + + expect($scans)->toHaveCount(2) + ->and($scans[0]['path'])->toBe($this->dir) + ->and($scans[0]['source'])->toBe('disk') + ->and($scans[1]['source'])->toBe('memory') + ->and($scans[1]['milliseconds'])->toBe(0.0); + }); + + it('tells a warm persistent cache from a scan that filled it', function (): void { + $container = new Container; + $container->instance('config', new Repository(['structure-discoverer' => ['cache' => ['driver' => StaticDiscoverCacheDriver::class]]])); + $container->instance(StaticDiscoverCacheDriver::class, new StaticDiscoverCacheDriver); + Container::setInstance($container); + + $detector = Mockery::mock(DebugDetectorInterface::class); + $detector->shouldReceive('isDebugMode')->andReturn(false); + + $first = new DiscoveryCacheManager($container, $detector); + scanOf($first, $this->dir); + + // A new process starts with an empty copy of its own, and only the + // persistent cache is left to answer. + (new ReflectionProperty(DiscoveryCacheManager::class, 'structuresCache'))->setValue(null, []); + + $second = new DiscoveryCacheManager($container, $detector); + scanOf($second, $this->dir); + + expect($first->scans()[0]['source'])->toBe('disk') + ->and($second->scans()[0]['source'])->toBe('cache'); + }); +}); From 15fbddadcdb07f24429b94bb42101f4704a70913 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 8 Oct 2026 16:00:17 +0200 Subject: [PATCH 5/9] feat: announce block binding resolutions and list asset containers BindingResolved is dispatched for each binding resolved (source, field, attribute, post, time, whether the request cache answered), only when something listens. AssetManager::containers() lists every asset container by name. --- .../Application/Services/AssetManager.php | 11 ++++++ .../Application/Services/BindingResolver.php | 36 ++++++++++++++++++- .../Domain/Events/BindingResolved.php | 34 ++++++++++++++++++ .../Asset/AssetManagerContainersTest.php | 23 ++++++++++++ .../Unit/BlockBinding/BindingResolverTest.php | 36 +++++++++++++++++++ 5 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 src/BlockBinding/Domain/Events/BindingResolved.php create mode 100644 tests/Feature/Asset/AssetManagerContainersTest.php diff --git a/src/Asset/Application/Services/AssetManager.php b/src/Asset/Application/Services/AssetManager.php index 7da44d1a..ffad0c2a 100755 --- a/src/Asset/Application/Services/AssetManager.php +++ b/src/Asset/Application/Services/AssetManager.php @@ -93,6 +93,17 @@ public function getContainer(string $name): ?AssetContainer return $this->containers[$name] ?? null; } + /** + * Every asset container, keyed by name: the theme's, each plugin's and + * module's, for tools that list where a page's assets come from. + * + * @return array + */ + public function containers(): array + { + return $this->containers; + } + /** * Sets the default asset container. * diff --git a/src/BlockBinding/Application/Services/BindingResolver.php b/src/BlockBinding/Application/Services/BindingResolver.php index 516fc31b..021ef28a 100644 --- a/src/BlockBinding/Application/Services/BindingResolver.php +++ b/src/BlockBinding/Application/Services/BindingResolver.php @@ -9,6 +9,7 @@ use Pollora\BlockBinding\Domain\Contracts\ContentVisibilityInterface; use Pollora\BlockBinding\Domain\Contracts\ValuePresenterInterface; use Pollora\BlockBinding\Domain\Enums\BindingFieldType; +use Pollora\BlockBinding\Domain\Events\BindingResolved; use Pollora\BlockBinding\Domain\Models\BindingContext; use Pollora\BlockBinding\Domain\Models\BindingFieldDefinition; use Pollora\BlockBinding\Domain\Models\BindingSource; @@ -112,12 +113,45 @@ private function resolveIn(BindingSource $source, array $args, array $blockConte $key = $this->cacheKey($source, $context, $attributeSource); if (array_key_exists($key, $this->resolved)) { + $this->announce($source, $field, $context, 0.0, true, $this->resolved[$key] !== null); + return $this->resolved[$key]; } + $start = hrtime(true); $value = $this->call($source, $field, $context); + $milliseconds = (hrtime(true) - $start) / 1_000_000; + + $this->resolved[$key] = $this->present($value, $field->type, $attributeSource); + $this->announce($source, $field, $context, $milliseconds, false, $this->resolved[$key] !== null); + + return $this->resolved[$key]; + } + + /** + * Tell debugging tools what was resolved, when one is listening. + */ + private function announce(BindingSource $source, BindingFieldDefinition $field, BindingContext $context, float $milliseconds, bool $cached, bool $hasValue): void + { + if (! $this->container->bound('events')) { + return; + } + + $events = $this->container->make('events'); + + if (! $events->hasListeners(BindingResolved::class)) { + return; + } - return $this->resolved[$key] = $this->present($value, $field->type, $attributeSource); + $events->dispatch(new BindingResolved( + source: $source->name, + field: $field->name, + attribute: $context->attribute, + postId: $context->postId, + milliseconds: round($milliseconds, 3), + cached: $cached, + hasValue: $hasValue, + )); } /** diff --git a/src/BlockBinding/Domain/Events/BindingResolved.php b/src/BlockBinding/Domain/Events/BindingResolved.php new file mode 100644 index 00000000..1c416599 --- /dev/null +++ b/src/BlockBinding/Domain/Events/BindingResolved.php @@ -0,0 +1,34 @@ +addContainer('theme', ['hot_file' => 'theme.hot']); + $manager->addContainer('acme-plugin', []); + + $containers = $manager->containers(); + + expect(array_keys($containers))->toBe(['theme', 'acme-plugin']) + ->and($containers['theme'])->toBeInstanceOf(AssetContainer::class) + ->and($containers['theme']->getName())->toBe('theme'); +}); + +it('lists nothing before a container is added', function (): void { + expect((new AssetManager(Mockery::mock(AssetRetrievalService::class)))->containers())->toBe([]); +}); diff --git a/tests/Unit/BlockBinding/BindingResolverTest.php b/tests/Unit/BlockBinding/BindingResolverTest.php index c0b15449..8c8ecadf 100644 --- a/tests/Unit/BlockBinding/BindingResolverTest.php +++ b/tests/Unit/BlockBinding/BindingResolverTest.php @@ -3,8 +3,10 @@ declare(strict_types=1); use Illuminate\Container\Container; +use Illuminate\Events\Dispatcher; use Pollora\BlockBinding\Application\Services\BindingResolver; use Pollora\BlockBinding\Application\Services\BindingSourceBuilder; +use Pollora\BlockBinding\Domain\Events\BindingResolved; use Psr\Log\LoggerInterface; use Tests\Unit\BlockBinding\Fixtures\EventBinding; use Tests\Unit\BlockBinding\Fixtures\FakePresenter; @@ -129,3 +131,37 @@ ->and($resolver->preview($this->event, ['field' => 'remaining_seats'], $context, 'title'))->toBe('14 seats & more') ->and($resolver->preview($this->event, ['field' => 'sold_out'], $context, 'title'))->toBe('Yes'); }); + +describe('announcing resolutions', function (): void { + beforeEach(function (): void { + $this->events = new Dispatcher($this->container); + $this->container->instance('events', $this->events); + $this->announced = []; + $this->events->listen(BindingResolved::class, function ($event): void { + $this->announced[] = $event; + }); + }); + + it('announces each resolution, and says when the request cache answered', function (): void { + $resolver = ($this->resolver)(); + + $resolver->resolve($this->event, ['field' => 'remaining_seats'], boundParagraph(), 'content'); + $resolver->resolve($this->event, ['field' => 'remaining_seats'], boundParagraph(), 'content'); + + expect($this->announced)->toHaveCount(2) + ->and($this->announced[0]->source)->toBe('acme/event') + ->and($this->announced[0]->field)->toBe('remaining_seats') + ->and($this->announced[0]->attribute)->toBe('content') + ->and($this->announced[0]->postId)->toBe(7) + ->and($this->announced[0]->cached)->toBeFalse() + ->and($this->announced[0]->hasValue)->toBeTrue() + ->and($this->announced[1]->cached)->toBeTrue() + ->and($this->announced[1]->milliseconds)->toBe(0.0); + }); + + it('says when a source left the block its own content', function (): void { + ($this->resolver)()->resolve($this->event, ['field' => 'nothing'], boundParagraph(), 'content'); + + expect($this->announced[0]->hasValue)->toBeFalse(); + }); +}); From e9e87423f3f1d53f45771a3eb6a761be377271ac Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 8 Oct 2026 18:10:55 +0200 Subject: [PATCH 6/9] docs(changelog): debugging extension points --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 580f803c..02b077ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tools › Pollora's Modules card and `pollora:status` say where module states are stored, and the card links to the Modules view - Update checks for modules installed by Composer: a module whose path is a Composer package's install path (placed in `Modules/` by the skeleton's `installer-paths` rule, or scanned in `vendor/`) carries that package's version, whatever its type; a local module has none and is never checked. The latest release comes from the repository the project's `composer.json` declares for it — a `composer` repository (Private Packagist, Satis: `{url}/p2/{package}.json`), a GitHub `vcs` repository (latest release, else highest tag; `MODULES_GITHUB_TOKEN` for a private one), or Packagist — is cached per package for 12 hours (1 hour when the source does not answer), and is fetched by a daily WP-Cron task or `pollora:module:outdated`, never during a front-end request. Plugins › Modules shows "1.3.0 · 1.4.0 available" with the `composer update` command, Site Health tests "Pollora modules are up to date", and `pollora:status` (`--json` too) gives each module's version and latest; a `dev-*` build is never reported outdated - `pollora:make:block --module=` creates a block in a module's `resources/views/blocks`, named after the module in kebab case (`blocks-demo/hero`), like the seven generators that already took `--module` +- `AnsweringTemplate` says which template the hierarchy answered a request with: template file, conditional tag, Blade view, whether it fell back to `index`. It stays empty when `Route::wp()` or a Laravel route answered, and works without `WP_DEBUG`, unlike the template marker +- `Pollora\WordPress\Events\WordPressBooting` is dispatched right before `wp-settings.php` loads: the last moment to set something up before WordPress builds its globals. Listen to it from a provider's `register()` +- `DiscoveryCacheManager::scans()` says, for each location, whether its structures came from this process, the persistent cache or a scan, with time and count. A cold and a warm persistent cache used to both count as a miss +- `Pollora\BlockBinding\Domain\Events\BindingResolved` is dispatched for each block binding resolved, with its source, field, post, time and whether the cache answered; only when something listens +- `AssetManager::containers()` lists every asset container by name: the theme's, plugins', modules' ### Fixed - Tools › Pollora and `pollora:status` reported 0 modules when one `module.json` had no `priority`: nwidart's `getPriority()` is typed `string` and threw From 6785805f11b565115e4957328b659b2e9f5d65cd Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Fri, 9 Oct 2026 10:40:47 +0200 Subject: [PATCH 7/9] chore(deps): require pollora/hook ^1.5 --- CHANGELOG.md | 3 +++ composer.json | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02b077ec..6ded7e70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Pollora\BlockBinding\Domain\Events\BindingResolved` is dispatched for each block binding resolved, with its source, field, post, time and whether the cache answered; only when something listens - `AssetManager::containers()` lists every asset container by name: the theme's, plugins', modules' +### Changed +- Requires `pollora/hook` `^1.5`, for `AbstractHook::all()` (every registration made through `Action` and `Filter`) and the `pollora/async/dispatched` action, which debugging tools read + ### Fixed - Tools › Pollora and `pollora:status` reported 0 modules when one `module.json` had no `priority`: nwidart's `getPriority()` is typed `string` and threw - Tools › Pollora and `pollora:status` showed every module as disabled: nwidart keys `allEnabled()` by lower-case name, which the module's own name never matched diff --git a/composer.json b/composer.json index a215a651..c7662e94 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "pollora/abilities": "^1.0", "pollora/ajax": "^1.1", "pollora/entity": "^1.2", - "pollora/hook": "^1.4", + "pollora/hook": "^1.5", "pollora/option": "^1.1", "watson/rememberable": "^7.1", "cweagans/composer-patches": "^2.0", From 5688d8d03cc44769f7c5998dc2a4f054fd79f78c Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Fri, 9 Oct 2026 13:41:45 +0200 Subject: [PATCH 8/9] chore: release v13.35.3 --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ded7e70..3704c1ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ All notable changes to the Pollora framework will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased](https://github.com/Pollora/framework/compare/v13.35.2...develop) +## [Unreleased](https://github.com/Pollora/framework/compare/v13.35.3...develop) + +## [v13.35.3](https://github.com/Pollora/framework/compare/v13.35.2...v13.35.3) - 2026-10-09 ### Added - `pollora:make:module Crm` creates a lean module from the new [Pollora/module-default](https://github.com/Pollora/module-default) template: classes discovered in `app/` (an example hooks class, no service provider), `resources/views/blocks`, and a Vite build through [`@pollora/vite-config`](https://github.com/Pollora/vite-config) into `public/build/module/`. Each Laravel layer is one flag away: `--provider`, `--routes` (implies `--provider`), `--api` (implies `--routes`), `--config`, `--database`, `--tests`, or `--full`; `--no-assets` for a PHP-only module. The module is enabled, `composer dump-autoload` merges its `composer.json`, and npm builds it (`--no-enable`, `--no-npm`). `--repository` and `--repo-version` pick another template; `--offline`, or a failed download, uses the copy bundled with the framework From 19f8a814240b75351ce5188dcd81f553d6d75c20 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Fri, 9 Oct 2026 13:42:48 +0200 Subject: [PATCH 9/9] docs(changelog): say which config/modules.php keeps nwidart's stock module --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3704c1ce..a00b9063 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `pollora:make:module Crm` creates a lean module from the new [Pollora/module-default](https://github.com/Pollora/module-default) template: classes discovered in `app/` (an example hooks class, no service provider), `resources/views/blocks`, and a Vite build through [`@pollora/vite-config`](https://github.com/Pollora/vite-config) into `public/build/module/`. Each Laravel layer is one flag away: `--provider`, `--routes` (implies `--provider`), `--api` (implies `--routes`), `--config`, `--database`, `--tests`, or `--full`; `--no-assets` for a PHP-only module. The module is enabled, `composer dump-autoload` merges its `composer.json`, and npm builds it (`--no-enable`, `--no-npm`). `--repository` and `--repo-version` pick another template; `--offline`, or a failed download, uses the copy bundled with the framework -- `module:make` writes the same lean module, offline, unless the project published `config/modules.php`: the framework turns off nwidart/laravel-modules' stock folders, files and classes and writes its bundled template once the module is created +- `module:make` writes the same lean module, offline, unless the project's `config/modules.php` sets `paths` or `stubs` (nwidart/laravel-modules' own published file; Pollora's, from `--tag=pollora-modules`, does not): the framework turns off nwidart/laravel-modules' stock folders, files and classes and writes its bundled template once the module is created - Every enabled module with a `vite.config.*` gets its `module.` asset container, so `Asset::add(...)->container('module.crm')` works without a provider of its own; it was set up only for modules with blocks - `pollora:make:block` recognises a `vite.config.js` built on `@pollora/vite-config`, which builds blocks already, instead of warning that it does not match the Pollora pattern - Module activation connectors: whether a Laravel module is enabled comes from a connector, through nwidart/laravel-modules' activator (`'activator' => 'pollora'`), so `module:enable`, `module:disable` and everything reading `app('modules')` keep working. `json` (default) reads and writes `modules_statuses.json` as nwidart does; `database` keeps a JSON map in the non-autoloaded `pollora_modules` WordPress option, read with Laravel's connection before WordPress loads (never unserialized) and written with `update_option()` once it has, falling back on `json` while the options table does not exist; `config` reads `connectors.config.states` or `MODULES_ENABLED` / `MODULES_DISABLED`, read-only. A project connector implements `ModuleStateConnector` and is declared in `connectors..class`, or given to `ModuleConnectors::extend()` in `bootstrap/app.php`