Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 12 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ 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/<kebab>`. 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.<kebab>` 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.<name>.class`, or given to `ModuleConnectors::extend()` in `bootstrap/app.php`
Expand All @@ -23,6 +25,14 @@ 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=<name>` 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'

### 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
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions src/Asset/Application/Services/AssetManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, AssetContainer>
*/
public function containers(): array
{
return $this->containers;
}

/**
* Sets the default asset container.
*
Expand Down
36 changes: 35 additions & 1 deletion src/BlockBinding/Application/Services/BindingResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
));
}

/**
Expand Down
34 changes: 34 additions & 0 deletions src/BlockBinding/Domain/Events/BindingResolved.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Pollora\BlockBinding\Domain\Events;

/**
* A block binding gave a value to a block attribute.
*
* For debugging tools: which source answered which attribute, on which post,
* and what it cost. Dispatched only when something listens, so a site
* without a listener pays nothing for it.
*/
final readonly class BindingResolved
{
/**
* @param string $source The source's name (`acme/reading-time`)
* @param string $field The field asked for, empty for an invokable source
* @param string $attribute The block attribute it fills
* @param int|null $postId The post the block was rendered for
* @param float $milliseconds Time spent in the source, 0 when the value came from this request's cache
* @param bool $cached Whether the same binding had already been resolved in this request
* @param bool $hasValue Whether the source returned a value (false: the block keeps its own)
*/
public function __construct(
public string $source,
public string $field,
public string $attribute,
public ?int $postId,
public float $milliseconds,
public bool $cached,
public bool $hasValue,
) {}
}
41 changes: 41 additions & 0 deletions src/Discovery/Infrastructure/Services/DiscoveryCacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ class DiscoveryCacheManager
*/
private readonly ?DiscoverCacheDriver $cacheDriver;

/**
* Where each location's structures came from in this process, in order.
*
* @var list<array{path: string, source: 'memory'|'cache'|'disk', milliseconds: float, structures: int}>
*/
private array $scans = [];

public function __construct(
private readonly Container $container,
private readonly DebugDetectorInterface $debugDetector
Expand All @@ -60,25 +67,59 @@ 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);
$structures = $discover->get();
$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<array{path: string, source: 'memory'|'cache'|'disk', milliseconds: float, structures: int}>
*/
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.
*
Expand Down
37 changes: 37 additions & 0 deletions src/Route/Application/Services/AnsweringTemplate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Pollora\Route\Application\Services;

use Pollora\Route\Domain\Models\TemplateResolution;

/**
* Remembers which template answered the current request.
*
* Filled by the frontend controller, read by debugging tools. A request that a
* `Route::wp()` route or a Laravel route answered never reaches the template
* hierarchy, so it leaves this empty — which is how a reader tells them apart.
*/
final class AnsweringTemplate
{
private ?TemplateResolution $resolution = null;

public function record(TemplateResolution $resolution): void
{
$this->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;
}
}
20 changes: 20 additions & 0 deletions src/Route/Domain/Enums/TemplateOutcome.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Pollora\Route\Domain\Enums;

/**
* How the template hierarchy ended up answering a request.
*/
enum TemplateOutcome: string
{
/** A Blade view rendered the page. */
case View = 'view';

/** A PHP file was included, as a block theme's template-canvas.php is. */
case File = 'file';

/** Nothing in the hierarchy could answer, so the 404 page did. */
case NotFound = 'not_found';
}
33 changes: 33 additions & 0 deletions src/Route/Domain/Models/TemplateResolution.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Pollora\Route\Domain\Models;

use Pollora\Route\Domain\Enums\TemplateOutcome;

/**
* What the template hierarchy settled on for one request.
*
* The frontend controller works this out and then throws it away; keeping it
* lets tooling say which template answered without running the hierarchy a
* second time — and a second run could disagree, since `template_include`
* filters are free to look at anything.
*/
final readonly class TemplateResolution
{
/**
* @param string $template The file `template_include` returned, empty when themes are off
* @param string|null $condition The conditional tag that picked the template (`is_single`…), null for the index fallback
* @param string|null $view The Blade view name, when the template maps to one
* @param bool $usedIndexFallback Whether no specific template matched and the index answered
* @param TemplateOutcome $outcome What finally rendered the response
*/
public function __construct(
public string $template,
public ?string $condition,
public ?string $view,
public bool $usedIndexFallback,
public TemplateOutcome $outcome,
) {}
}
5 changes: 5 additions & 0 deletions src/Route/Infrastructure/Providers/RouteServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Pollora\Route\Application\Services\AnsweringTemplate;
use Pollora\Route\Application\UseCases\BindWordPressParametersUseCase;
use Pollora\Route\Application\UseCases\RegisterWordPressTypesUseCase;
use Pollora\Route\Domain\Contracts\ConditionResolverInterface;
Expand Down Expand Up @@ -87,6 +88,10 @@ public function boot(): void
*/
private function registerDomainContracts(): void
{
// Which template answered: written by the frontend controller, read
// by debugging tools later in the same request
$this->app->singleton(AnsweringTemplate::class);

// Register the WordPress type resolver
$this->app->singleton(WordPressTypeResolverInterface::class, WordPressTypeResolver::class);

Expand Down
Loading
Loading