diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml
index c6bd417..d122ce7 100644
--- a/.github/workflows/php.yml
+++ b/.github/workflows/php.yml
@@ -23,7 +23,7 @@ jobs:
- name: Setup PHP with composer v2
uses: shivammathur/setup-php@v2
with:
- php-version: ${{ env.PHP_EXTENSIONS }}
+ php-version: ${{ env.PHP_VERSION }}
tools: composer:v2
- name: Validate composer.json and composer.lock
@@ -45,7 +45,7 @@ jobs:
- name: Setup PHP with composer v2
uses: shivammathur/setup-php@v2
with:
- php-version: ${{ env.PHP_EXTENSIONS }}
+ php-version: ${{ env.PHP_VERSION }}
tools: composer:v2
- name: Install dependencies
@@ -64,7 +64,7 @@ jobs:
- name: Setup PHP with composer v2
uses: shivammathur/setup-php@v2
with:
- php-version: ${{ env.PHP_EXTENSIONS }}
+ php-version: ${{ env.PHP_VERSION }}
tools: composer:v2
- name: Install dependencies
diff --git a/.gitignore b/.gitignore
index 106a68b..675fea7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
vendor/
coverage/
.phpunit.result.cache
+
+# Generated class-loader cache (a build artefact, not source)
+class-loader-cache/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ca9040d..8455eb4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file, per [the Keep a Changelog standard](http://keepachangelog.com/) and will adhere to [Semantic Versioning](http://semver.org/).
## [Unreleased] - TBD
+### Added
+- Build-time class-cache generation: a `tenup-framework-generate-class-cache` command (installed to `vendor/bin/`) and a `composer generate-class-cache` alias that build the cache in CI without bootstrapping WordPress. See [Build and Deployment](docs/Build-and-Deployment.md) ([#30](https://github.com/10up/wp-framework/issues/30)).
+- Hidden admin page (`admin.php?page=tenup-framework-loaders`, `manage_options`) that aggregates every class-loader cache on the site — across all framework copies — and shows each cache's path, status, loaded classes, and an on-demand live-vs-cache staleness check. Admin-only (no front-end overhead) and read-only. Disable with the `tenup_framework_enable_loader_debug` filter or the `TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG` constant. See [Debugging class loaders](docs/Debugging.md).
+- The loader debug page reports per-loader timing: how long class **discovery** took (a cache read when cached, a live filesystem scan otherwise) and how long **class lookup** (reflection, instantiation and registration) took. The staleness check also reports how long its live discovery ran, so the cache's saving on a given site is measurable.
+
+### Changed
+- **Breaking: the minimum required PHP version is now 8.3** (raised from 8.2). Full native type coverage relies on [typed class constants](https://www.php.net/manual/en/language.oop5.constants.php), which are a PHP 8.3 feature and are a parse error on 8.2. This reverses the lowering to 8.2 made in 1.2.0 ([#8](https://github.com/10up/wp-framework/pull/8)). See the [Upgrade Guide](docs/Upgrade-Guide.md).
+- **Breaking (static analysis only): `AbstractPostType::get_name()` and `AbstractTaxonomy::get_name()` now document a `lowercase-string&non-empty-string` return type**, stating what WordPress already requires of a post type or taxonomy key. The native `: string` signature is unchanged, so no subclass breaks and no runtime behaviour differs, but a project running PHPStan may need to narrow how it builds a dynamic key. See the [Upgrade Guide](docs/Upgrade-Guide.md).
+- Full native type coverage across `src/`: every return type, parameter, property and class constant now declares a native type, and the generic types of every `array` are documented. Enforced by `tomasvotruba/type-coverage` through `composer run static`, which also no longer ignores `missingType.iterableValue`.
+- The class-loader cache is now **read-only at runtime** and opt-in. The framework reads a pre-built cache if present and discovers live otherwise, but never writes one on the server — fixing stale caches that could only be cleared by hand ([#30](https://github.com/10up/wp-framework/issues/30)).
+- A corrupt or truncated shipped cache is caught at runtime and the request falls back to a live scan instead of fataling, so a bad cache degrades performance rather than taking the site down. The fallback fires a `tenup_framework_cache_load_failed` action (for logging or alerting) and the loader debug page flags that loader red as "Cache failed to load — running live" instead of reporting it as in use.
+- Bumped the cache filename so a cache written by an older version is ignored after upgrade rather than served stale.
+- `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` now forces live discovery (ignores any shipped cache).
+
+### Removed
+- Automatic runtime cache generation and its environment gating — `should_use_cache()`, the `production`/`staging` checks, and the `VIP_GO_APP_ENVIRONMENT` handling. Caching is now produced at build time instead.
## [1.2.0] - 2025-03-20
### Changed
diff --git a/README.md b/README.md
index 2d5b789..8bdced8 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
- **Shared Functionality:** Provides commonly used abstract classes and utilities to reduce boilerplate code in WordPress projects.
- **Extendability:** Built for easy extension. Engineers can subclass or override functionality as needed to tailor it to their projects.
- **Centralized Updates:** Simplifies rolling out updates and new features across projects using this framework.
-- **Modern Standards:** Compatible with PHP 8.2+ and adheres to modern development practices.
+- **Modern Standards:** Compatible with PHP 8.3+ and adheres to modern development practices.
## Installation
diff --git a/bin/tenup-framework-generate-class-cache b/bin/tenup-framework-generate-class-cache
new file mode 100755
index 0000000..23458fb
--- /dev/null
+++ b/bin/tenup-framework-generate-class-cache
@@ -0,0 +1,76 @@
+#!/usr/bin/env php
+ [
...]
+ *
+ * Pass the same directory (or directories) you pass to
+ * TenupFramework\ModuleInitialization::init_classes() — usually your plugin/theme `inc/`.
+ *
+ * @package TenupFramework
+ */
+
+declare( strict_types = 1 );
+
+namespace TenupFramework\Bin;
+
+use TenupFramework\ModuleInitialization;
+use Throwable;
+
+// Locate the Composer autoloader whether this runs from within the package itself
+// (development) or installed inside a consumer project's vendor directory. Composer 2.2+
+// exposes the path via this global from the generated bin proxy; otherwise fall back to
+// the known relative locations.
+$tenup_autoloader_loaded = false;
+
+if ( isset( $GLOBALS['_composer_autoload_path'] ) && file_exists( (string) $GLOBALS['_composer_autoload_path'] ) ) {
+ require $GLOBALS['_composer_autoload_path'];
+ $tenup_autoloader_loaded = true;
+} else {
+ $tenup_autoload_candidates = [
+ __DIR__ . '/../vendor/autoload.php', // Running from the package itself.
+ __DIR__ . '/../../../autoload.php', // Installed at vendor/10up/wp-framework/bin.
+ ];
+
+ foreach ( $tenup_autoload_candidates as $tenup_autoload_candidate ) {
+ if ( file_exists( $tenup_autoload_candidate ) ) {
+ require $tenup_autoload_candidate;
+ $tenup_autoloader_loaded = true;
+ break;
+ }
+ }
+}
+
+if ( ! $tenup_autoloader_loaded ) {
+ fwrite( STDERR, "Could not locate the Composer autoloader. Run `composer install` first.\n" );
+ exit( 1 );
+}
+
+// Target directories are the CLI arguments (everything after the script name).
+$tenup_directories = array_slice( $argv, 1 );
+
+if ( empty( $tenup_directories ) ) {
+ fwrite( STDERR, "Usage: tenup-framework-generate-class-cache [ ...]\n" );
+ fwrite( STDERR, "Pass one or more directories (the same ones passed to ModuleInitialization::init_classes()).\n" );
+ exit( 1 );
+}
+
+$tenup_exit_code = 0;
+
+foreach ( $tenup_directories as $tenup_directory ) {
+ try {
+ $tenup_classes = ModuleInitialization::instance()->generate_cache( $tenup_directory );
+ fwrite( STDOUT, sprintf( "Cached %d class(es) for %s\n", count( $tenup_classes ), $tenup_directory ) );
+ } catch ( Throwable $tenup_exception ) {
+ fwrite( STDERR, sprintf( "Failed to generate cache for %s: %s\n", $tenup_directory, $tenup_exception->getMessage() ) );
+ $tenup_exit_code = 1;
+ }
+}
+
+exit( $tenup_exit_code );
diff --git a/composer.json b/composer.json
index a97cfc4..61052d3 100644
--- a/composer.json
+++ b/composer.json
@@ -18,6 +18,9 @@
"role": "Developer"
}
],
+ "bin": [
+ "bin/tenup-framework-generate-class-cache"
+ ],
"autoload": {
"psr-4": {
"TenupFramework\\": "src/"
@@ -30,7 +33,7 @@
}
},
"require": {
- "php": ">=8.2",
+ "php": ">=8.3",
"spatie/php-structure-discoverer": "^2.2"
},
"require-dev": {
@@ -41,9 +44,10 @@
"php-stubs/wp-cli-stubs": "^2.11",
"phpstan/phpstan-deprecation-rules": "^2.0",
"10up/phpcs-composer": "^3.0",
- "phpcompatibility/php-compatibility": "dev-develop as 9.99.99",
"phpunit/php-code-coverage": "^9.2",
- "slevomat/coding-standard": "^8.15"
+ "slevomat/coding-standard": "^8.15",
+ "rector/rector": "^2.0",
+ "tomasvotruba/type-coverage": "^2.0"
},
"scripts": {
"test": "XDEBUG_MODE=coverage ./vendor/bin/phpunit",
@@ -52,7 +56,15 @@
"static": [
"Composer\\Config::disableProcessTimeout",
"phpstan --memory-limit=1G"
- ]
+ ],
+ "rector": [
+ "./vendor/bin/rector",
+ "composer run lint-fix"
+ ],
+ "generate-class-cache": "@php bin/tenup-framework-generate-class-cache"
+ },
+ "scripts-descriptions": {
+ "generate-class-cache": "Generate the class-loader cache for one or more directories, e.g. `composer generate-class-cache -- inc/`."
},
"config": {
"allow-plugins": {
diff --git a/composer.lock b/composer.lock
index 519081b..2406e39 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,887 +4,39 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "5c674b2bd34e9e2105a937d69259c184",
+ "content-hash": "2e94fb31328e23fbe3ad4266f9fd54bf",
"packages": [
- {
- "name": "amphp/amp",
- "version": "v3.1.0",
- "source": {
- "type": "git",
- "url": "https://github.com/amphp/amp.git",
- "reference": "7cf7fef3d667bfe4b2560bc87e67d5387a7bcde9"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/amphp/amp/zipball/7cf7fef3d667bfe4b2560bc87e67d5387a7bcde9",
- "reference": "7cf7fef3d667bfe4b2560bc87e67d5387a7bcde9",
- "shasum": ""
- },
- "require": {
- "php": ">=8.1",
- "revolt/event-loop": "^1 || ^0.2"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "^2",
- "phpunit/phpunit": "^9",
- "psalm/phar": "5.23.1"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/functions.php",
- "src/Future/functions.php",
- "src/Internal/functions.php"
- ],
- "psr-4": {
- "Amp\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- },
- {
- "name": "Bob Weinand",
- "email": "bobwei9@hotmail.com"
- },
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- },
- {
- "name": "Daniel Lowrey",
- "email": "rdlowrey@php.net"
- }
- ],
- "description": "A non-blocking concurrency framework for PHP applications.",
- "homepage": "https://amphp.org/amp",
- "keywords": [
- "async",
- "asynchronous",
- "awaitable",
- "concurrency",
- "event",
- "event-loop",
- "future",
- "non-blocking",
- "promise"
- ],
- "support": {
- "issues": "https://github.com/amphp/amp/issues",
- "source": "https://github.com/amphp/amp/tree/v3.1.0"
- },
- "funding": [
- {
- "url": "https://github.com/amphp",
- "type": "github"
- }
- ],
- "time": "2025-01-26T16:07:39+00:00"
- },
- {
- "name": "amphp/byte-stream",
- "version": "v2.1.2",
- "source": {
- "type": "git",
- "url": "https://github.com/amphp/byte-stream.git",
- "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46",
- "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46",
- "shasum": ""
- },
- "require": {
- "amphp/amp": "^3",
- "amphp/parser": "^1.1",
- "amphp/pipeline": "^1",
- "amphp/serialization": "^1",
- "amphp/sync": "^2",
- "php": ">=8.1",
- "revolt/event-loop": "^1 || ^0.2.3"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "^2",
- "amphp/phpunit-util": "^3",
- "phpunit/phpunit": "^9",
- "psalm/phar": "5.22.1"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/functions.php",
- "src/Internal/functions.php"
- ],
- "psr-4": {
- "Amp\\ByteStream\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- },
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- }
- ],
- "description": "A stream abstraction to make working with non-blocking I/O simple.",
- "homepage": "https://amphp.org/byte-stream",
- "keywords": [
- "amp",
- "amphp",
- "async",
- "io",
- "non-blocking",
- "stream"
- ],
- "support": {
- "issues": "https://github.com/amphp/byte-stream/issues",
- "source": "https://github.com/amphp/byte-stream/tree/v2.1.2"
- },
- "funding": [
- {
- "url": "https://github.com/amphp",
- "type": "github"
- }
- ],
- "time": "2025-03-16T17:10:27+00:00"
- },
- {
- "name": "amphp/cache",
- "version": "v2.0.1",
- "source": {
- "type": "git",
- "url": "https://github.com/amphp/cache.git",
- "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c",
- "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c",
- "shasum": ""
- },
- "require": {
- "amphp/amp": "^3",
- "amphp/serialization": "^1",
- "amphp/sync": "^2",
- "php": ">=8.1",
- "revolt/event-loop": "^1 || ^0.2"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "^2",
- "amphp/phpunit-util": "^3",
- "phpunit/phpunit": "^9",
- "psalm/phar": "^5.4"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Amp\\Cache\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- },
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- },
- {
- "name": "Daniel Lowrey",
- "email": "rdlowrey@php.net"
- }
- ],
- "description": "A fiber-aware cache API based on Amp and Revolt.",
- "homepage": "https://amphp.org/cache",
- "support": {
- "issues": "https://github.com/amphp/cache/issues",
- "source": "https://github.com/amphp/cache/tree/v2.0.1"
- },
- "funding": [
- {
- "url": "https://github.com/amphp",
- "type": "github"
- }
- ],
- "time": "2024-04-19T03:38:06+00:00"
- },
- {
- "name": "amphp/dns",
- "version": "v2.4.0",
- "source": {
- "type": "git",
- "url": "https://github.com/amphp/dns.git",
- "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71",
- "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71",
- "shasum": ""
- },
- "require": {
- "amphp/amp": "^3",
- "amphp/byte-stream": "^2",
- "amphp/cache": "^2",
- "amphp/parser": "^1",
- "amphp/process": "^2",
- "daverandom/libdns": "^2.0.2",
- "ext-filter": "*",
- "ext-json": "*",
- "php": ">=8.1",
- "revolt/event-loop": "^1 || ^0.2"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "^2",
- "amphp/phpunit-util": "^3",
- "phpunit/phpunit": "^9",
- "psalm/phar": "5.20"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/functions.php"
- ],
- "psr-4": {
- "Amp\\Dns\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Chris Wright",
- "email": "addr@daverandom.com"
- },
- {
- "name": "Daniel Lowrey",
- "email": "rdlowrey@php.net"
- },
- {
- "name": "Bob Weinand",
- "email": "bobwei9@hotmail.com"
- },
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- },
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- }
- ],
- "description": "Async DNS resolution for Amp.",
- "homepage": "https://github.com/amphp/dns",
- "keywords": [
- "amp",
- "amphp",
- "async",
- "client",
- "dns",
- "resolve"
- ],
- "support": {
- "issues": "https://github.com/amphp/dns/issues",
- "source": "https://github.com/amphp/dns/tree/v2.4.0"
- },
- "funding": [
- {
- "url": "https://github.com/amphp",
- "type": "github"
- }
- ],
- "time": "2025-01-19T15:43:40+00:00"
- },
- {
- "name": "amphp/parallel",
- "version": "v2.3.1",
- "source": {
- "type": "git",
- "url": "https://github.com/amphp/parallel.git",
- "reference": "5113111de02796a782f5d90767455e7391cca190"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/amphp/parallel/zipball/5113111de02796a782f5d90767455e7391cca190",
- "reference": "5113111de02796a782f5d90767455e7391cca190",
- "shasum": ""
- },
- "require": {
- "amphp/amp": "^3",
- "amphp/byte-stream": "^2",
- "amphp/cache": "^2",
- "amphp/parser": "^1",
- "amphp/pipeline": "^1",
- "amphp/process": "^2",
- "amphp/serialization": "^1",
- "amphp/socket": "^2",
- "amphp/sync": "^2",
- "php": ">=8.1",
- "revolt/event-loop": "^1"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "^2",
- "amphp/phpunit-util": "^3",
- "phpunit/phpunit": "^9",
- "psalm/phar": "^5.18"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/Context/functions.php",
- "src/Context/Internal/functions.php",
- "src/Ipc/functions.php",
- "src/Worker/functions.php"
- ],
- "psr-4": {
- "Amp\\Parallel\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- },
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- },
- {
- "name": "Stephen Coakley",
- "email": "me@stephencoakley.com"
- }
- ],
- "description": "Parallel processing component for Amp.",
- "homepage": "https://github.com/amphp/parallel",
- "keywords": [
- "async",
- "asynchronous",
- "concurrent",
- "multi-processing",
- "multi-threading"
- ],
- "support": {
- "issues": "https://github.com/amphp/parallel/issues",
- "source": "https://github.com/amphp/parallel/tree/v2.3.1"
- },
- "funding": [
- {
- "url": "https://github.com/amphp",
- "type": "github"
- }
- ],
- "time": "2024-12-21T01:56:09+00:00"
- },
- {
- "name": "amphp/parser",
- "version": "v1.1.1",
- "source": {
- "type": "git",
- "url": "https://github.com/amphp/parser.git",
- "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7",
- "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7",
- "shasum": ""
- },
- "require": {
- "php": ">=7.4"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "^2",
- "phpunit/phpunit": "^9",
- "psalm/phar": "^5.4"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Amp\\Parser\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- },
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- }
- ],
- "description": "A generator parser to make streaming parsers simple.",
- "homepage": "https://github.com/amphp/parser",
- "keywords": [
- "async",
- "non-blocking",
- "parser",
- "stream"
- ],
- "support": {
- "issues": "https://github.com/amphp/parser/issues",
- "source": "https://github.com/amphp/parser/tree/v1.1.1"
- },
- "funding": [
- {
- "url": "https://github.com/amphp",
- "type": "github"
- }
- ],
- "time": "2024-03-21T19:16:53+00:00"
- },
- {
- "name": "amphp/pipeline",
- "version": "v1.2.3",
- "source": {
- "type": "git",
- "url": "https://github.com/amphp/pipeline.git",
- "reference": "7b52598c2e9105ebcddf247fc523161581930367"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/amphp/pipeline/zipball/7b52598c2e9105ebcddf247fc523161581930367",
- "reference": "7b52598c2e9105ebcddf247fc523161581930367",
- "shasum": ""
- },
- "require": {
- "amphp/amp": "^3",
- "php": ">=8.1",
- "revolt/event-loop": "^1"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "^2",
- "amphp/phpunit-util": "^3",
- "phpunit/phpunit": "^9",
- "psalm/phar": "^5.18"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Amp\\Pipeline\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- },
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- }
- ],
- "description": "Asynchronous iterators and operators.",
- "homepage": "https://amphp.org/pipeline",
- "keywords": [
- "amp",
- "amphp",
- "async",
- "io",
- "iterator",
- "non-blocking"
- ],
- "support": {
- "issues": "https://github.com/amphp/pipeline/issues",
- "source": "https://github.com/amphp/pipeline/tree/v1.2.3"
- },
- "funding": [
- {
- "url": "https://github.com/amphp",
- "type": "github"
- }
- ],
- "time": "2025-03-16T16:33:53+00:00"
- },
- {
- "name": "amphp/process",
- "version": "v2.0.3",
- "source": {
- "type": "git",
- "url": "https://github.com/amphp/process.git",
- "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/amphp/process/zipball/52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d",
- "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d",
- "shasum": ""
- },
- "require": {
- "amphp/amp": "^3",
- "amphp/byte-stream": "^2",
- "amphp/sync": "^2",
- "php": ">=8.1",
- "revolt/event-loop": "^1 || ^0.2"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "^2",
- "amphp/phpunit-util": "^3",
- "phpunit/phpunit": "^9",
- "psalm/phar": "^5.4"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/functions.php"
- ],
- "psr-4": {
- "Amp\\Process\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Bob Weinand",
- "email": "bobwei9@hotmail.com"
- },
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- },
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- }
- ],
- "description": "A fiber-aware process manager based on Amp and Revolt.",
- "homepage": "https://amphp.org/process",
- "support": {
- "issues": "https://github.com/amphp/process/issues",
- "source": "https://github.com/amphp/process/tree/v2.0.3"
- },
- "funding": [
- {
- "url": "https://github.com/amphp",
- "type": "github"
- }
- ],
- "time": "2024-04-19T03:13:44+00:00"
- },
- {
- "name": "amphp/serialization",
- "version": "v1.0.0",
- "source": {
- "type": "git",
- "url": "https://github.com/amphp/serialization.git",
- "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/amphp/serialization/zipball/693e77b2fb0b266c3c7d622317f881de44ae94a1",
- "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1",
- "shasum": ""
- },
- "require": {
- "php": ">=7.1"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "dev-master",
- "phpunit/phpunit": "^9 || ^8 || ^7"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/functions.php"
- ],
- "psr-4": {
- "Amp\\Serialization\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- },
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- }
- ],
- "description": "Serialization tools for IPC and data storage in PHP.",
- "homepage": "https://github.com/amphp/serialization",
- "keywords": [
- "async",
- "asynchronous",
- "serialization",
- "serialize"
- ],
- "support": {
- "issues": "https://github.com/amphp/serialization/issues",
- "source": "https://github.com/amphp/serialization/tree/master"
- },
- "time": "2020-03-25T21:39:07+00:00"
- },
- {
- "name": "amphp/socket",
- "version": "v2.3.1",
- "source": {
- "type": "git",
- "url": "https://github.com/amphp/socket.git",
- "reference": "58e0422221825b79681b72c50c47a930be7bf1e1"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/amphp/socket/zipball/58e0422221825b79681b72c50c47a930be7bf1e1",
- "reference": "58e0422221825b79681b72c50c47a930be7bf1e1",
- "shasum": ""
- },
- "require": {
- "amphp/amp": "^3",
- "amphp/byte-stream": "^2",
- "amphp/dns": "^2",
- "ext-openssl": "*",
- "kelunik/certificate": "^1.1",
- "league/uri": "^6.5 | ^7",
- "league/uri-interfaces": "^2.3 | ^7",
- "php": ">=8.1",
- "revolt/event-loop": "^1 || ^0.2"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "^2",
- "amphp/phpunit-util": "^3",
- "amphp/process": "^2",
- "phpunit/phpunit": "^9",
- "psalm/phar": "5.20"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/functions.php",
- "src/Internal/functions.php",
- "src/SocketAddress/functions.php"
- ],
- "psr-4": {
- "Amp\\Socket\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Daniel Lowrey",
- "email": "rdlowrey@gmail.com"
- },
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- },
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- }
- ],
- "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.",
- "homepage": "https://github.com/amphp/socket",
- "keywords": [
- "amp",
- "async",
- "encryption",
- "non-blocking",
- "sockets",
- "tcp",
- "tls"
- ],
- "support": {
- "issues": "https://github.com/amphp/socket/issues",
- "source": "https://github.com/amphp/socket/tree/v2.3.1"
- },
- "funding": [
- {
- "url": "https://github.com/amphp",
- "type": "github"
- }
- ],
- "time": "2024-04-21T14:33:03+00:00"
- },
- {
- "name": "amphp/sync",
- "version": "v2.3.0",
- "source": {
- "type": "git",
- "url": "https://github.com/amphp/sync.git",
- "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1",
- "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1",
- "shasum": ""
- },
- "require": {
- "amphp/amp": "^3",
- "amphp/pipeline": "^1",
- "amphp/serialization": "^1",
- "php": ">=8.1",
- "revolt/event-loop": "^1 || ^0.2"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "^2",
- "amphp/phpunit-util": "^3",
- "phpunit/phpunit": "^9",
- "psalm/phar": "5.23"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/functions.php"
- ],
- "psr-4": {
- "Amp\\Sync\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- },
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- },
- {
- "name": "Stephen Coakley",
- "email": "me@stephencoakley.com"
- }
- ],
- "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.",
- "homepage": "https://github.com/amphp/sync",
- "keywords": [
- "async",
- "asynchronous",
- "mutex",
- "semaphore",
- "synchronization"
- ],
- "support": {
- "issues": "https://github.com/amphp/sync/issues",
- "source": "https://github.com/amphp/sync/tree/v2.3.0"
- },
- "funding": [
- {
- "url": "https://github.com/amphp",
- "type": "github"
- }
- ],
- "time": "2024-08-03T19:31:26+00:00"
- },
- {
- "name": "daverandom/libdns",
- "version": "v2.1.0",
- "source": {
- "type": "git",
- "url": "https://github.com/DaveRandom/LibDNS.git",
- "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a",
- "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a",
- "shasum": ""
- },
- "require": {
- "ext-ctype": "*",
- "php": ">=7.1"
- },
- "suggest": {
- "ext-intl": "Required for IDN support"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/functions.php"
- ],
- "psr-4": {
- "LibDNS\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "description": "DNS protocol implementation written in pure PHP",
- "keywords": [
- "dns"
- ],
- "support": {
- "issues": "https://github.com/DaveRandom/LibDNS/issues",
- "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0"
- },
- "time": "2024-04-12T12:12:48+00:00"
- },
{
"name": "illuminate/collections",
- "version": "v12.3.0",
+ "version": "v13.23.0",
"source": {
"type": "git",
"url": "https://github.com/illuminate/collections.git",
- "reference": "0094b162fa505126c1391222f27fd98734d24525"
+ "reference": "79b5d04507a255e35ce34cec621696aca84ee242"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/illuminate/collections/zipball/0094b162fa505126c1391222f27fd98734d24525",
- "reference": "0094b162fa505126c1391222f27fd98734d24525",
+ "url": "https://api.github.com/repos/illuminate/collections/zipball/79b5d04507a255e35ce34cec621696aca84ee242",
+ "reference": "79b5d04507a255e35ce34cec621696aca84ee242",
"shasum": ""
},
"require": {
- "illuminate/conditionable": "^12.0",
- "illuminate/contracts": "^12.0",
- "illuminate/macroable": "^12.0",
- "php": "^8.2"
+ "illuminate/conditionable": "^13.0",
+ "illuminate/contracts": "^13.0",
+ "illuminate/macroable": "^13.0",
+ "php": "^8.3",
+ "symfony/polyfill-php84": "^1.36",
+ "symfony/polyfill-php85": "^1.36",
+ "symfony/polyfill-php86": "^1.36"
},
"suggest": {
- "symfony/var-dumper": "Required to use the dump method (^7.2)."
+ "illuminate/http": "Required to convert collections to API resources (^13.0).",
+ "symfony/var-dumper": "Required to use the dump method (^7.4 || ^8.0)."
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "12.x-dev"
+ "dev-master": "13.0.x-dev"
}
},
"autoload": {
@@ -912,29 +64,29 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
- "time": "2025-03-16T23:50:18+00:00"
+ "time": "2026-07-26T13:07:32+00:00"
},
{
"name": "illuminate/conditionable",
- "version": "v12.3.0",
+ "version": "v13.23.0",
"source": {
"type": "git",
"url": "https://github.com/illuminate/conditionable.git",
- "reference": "a2b3c66f3ca532e12e694bd5c9254adc303b922d"
+ "reference": "7f1ef52d9a346f829421b296adfb7644a951b216"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/illuminate/conditionable/zipball/a2b3c66f3ca532e12e694bd5c9254adc303b922d",
- "reference": "a2b3c66f3ca532e12e694bd5c9254adc303b922d",
+ "url": "https://api.github.com/repos/illuminate/conditionable/zipball/7f1ef52d9a346f829421b296adfb7644a951b216",
+ "reference": "7f1ef52d9a346f829421b296adfb7644a951b216",
"shasum": ""
},
"require": {
- "php": "^8.2"
+ "php": "^8.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "12.x-dev"
+ "dev-master": "13.0.x-dev"
}
},
"autoload": {
@@ -958,31 +110,31 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
- "time": "2025-02-19T19:08:33+00:00"
+ "time": "2026-02-25T16:07:55+00:00"
},
{
"name": "illuminate/contracts",
- "version": "v12.3.0",
+ "version": "v13.23.0",
"source": {
"type": "git",
"url": "https://github.com/illuminate/contracts.git",
- "reference": "88962e0a73fb837e048ebdbbc67afd2f6b30e8e6"
+ "reference": "98179ca5d07025c2fe4c5c70e0b57b5284fa0071"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/illuminate/contracts/zipball/88962e0a73fb837e048ebdbbc67afd2f6b30e8e6",
- "reference": "88962e0a73fb837e048ebdbbc67afd2f6b30e8e6",
+ "url": "https://api.github.com/repos/illuminate/contracts/zipball/98179ca5d07025c2fe4c5c70e0b57b5284fa0071",
+ "reference": "98179ca5d07025c2fe4c5c70e0b57b5284fa0071",
"shasum": ""
},
"require": {
- "php": "^8.2",
- "psr/container": "^1.1.1|^2.0.1",
- "psr/simple-cache": "^1.0|^2.0|^3.0"
+ "php": "^8.3",
+ "psr/container": "^1.1.1 || ^2.0.1",
+ "psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "12.x-dev"
+ "dev-master": "13.0.x-dev"
}
},
"autoload": {
@@ -1006,238 +158,34 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
- "time": "2025-03-16T23:56:53+00:00"
+ "time": "2026-07-15T15:50:17+00:00"
},
{
"name": "illuminate/macroable",
- "version": "v12.3.0",
- "source": {
- "type": "git",
- "url": "https://github.com/illuminate/macroable.git",
- "reference": "e862e5648ee34004fa56046b746f490dfa86c613"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/illuminate/macroable/zipball/e862e5648ee34004fa56046b746f490dfa86c613",
- "reference": "e862e5648ee34004fa56046b746f490dfa86c613",
- "shasum": ""
- },
- "require": {
- "php": "^8.2"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "12.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Illuminate\\Support\\": ""
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Taylor Otwell",
- "email": "taylor@laravel.com"
- }
- ],
- "description": "The Illuminate Macroable package.",
- "homepage": "https://laravel.com",
- "support": {
- "issues": "https://github.com/laravel/framework/issues",
- "source": "https://github.com/laravel/framework"
- },
- "time": "2024-07-23T16:31:01+00:00"
- },
- {
- "name": "kelunik/certificate",
- "version": "v1.1.3",
- "source": {
- "type": "git",
- "url": "https://github.com/kelunik/certificate.git",
- "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e",
- "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e",
- "shasum": ""
- },
- "require": {
- "ext-openssl": "*",
- "php": ">=7.0"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "^2",
- "phpunit/phpunit": "^6 | 7 | ^8 | ^9"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "1.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Kelunik\\Certificate\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- }
- ],
- "description": "Access certificate details and transform between different formats.",
- "keywords": [
- "DER",
- "certificate",
- "certificates",
- "openssl",
- "pem",
- "x509"
- ],
- "support": {
- "issues": "https://github.com/kelunik/certificate/issues",
- "source": "https://github.com/kelunik/certificate/tree/v1.1.3"
- },
- "time": "2023-02-03T21:26:53+00:00"
- },
- {
- "name": "league/uri",
- "version": "7.5.1",
- "source": {
- "type": "git",
- "url": "https://github.com/thephpleague/uri.git",
- "reference": "81fb5145d2644324614cc532b28efd0215bda430"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430",
- "reference": "81fb5145d2644324614cc532b28efd0215bda430",
- "shasum": ""
- },
- "require": {
- "league/uri-interfaces": "^7.5",
- "php": "^8.1"
- },
- "conflict": {
- "league/uri-schemes": "^1.0"
- },
- "suggest": {
- "ext-bcmath": "to improve IPV4 host parsing",
- "ext-fileinfo": "to create Data URI from file contennts",
- "ext-gmp": "to improve IPV4 host parsing",
- "ext-intl": "to handle IDN host with the best performance",
- "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain",
- "league/uri-components": "Needed to easily manipulate URI objects components",
- "php-64bit": "to improve IPV4 host parsing",
- "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "7.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "League\\Uri\\": ""
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Ignace Nyamagana Butera",
- "email": "nyamsprod@gmail.com",
- "homepage": "https://nyamsprod.com"
- }
- ],
- "description": "URI manipulation library",
- "homepage": "https://uri.thephpleague.com",
- "keywords": [
- "data-uri",
- "file-uri",
- "ftp",
- "hostname",
- "http",
- "https",
- "middleware",
- "parse_str",
- "parse_url",
- "psr-7",
- "query-string",
- "querystring",
- "rfc3986",
- "rfc3987",
- "rfc6570",
- "uri",
- "uri-template",
- "url",
- "ws"
- ],
- "support": {
- "docs": "https://uri.thephpleague.com",
- "forum": "https://thephpleague.slack.com",
- "issues": "https://github.com/thephpleague/uri-src/issues",
- "source": "https://github.com/thephpleague/uri/tree/7.5.1"
- },
- "funding": [
- {
- "url": "https://github.com/sponsors/nyamsprod",
- "type": "github"
- }
- ],
- "time": "2024-12-08T08:40:02+00:00"
- },
- {
- "name": "league/uri-interfaces",
- "version": "7.5.0",
+ "version": "v13.23.0",
"source": {
"type": "git",
- "url": "https://github.com/thephpleague/uri-interfaces.git",
- "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742"
+ "url": "https://github.com/illuminate/macroable.git",
+ "reference": "59b5b5f3cf290a91db8cf6cd3d35ff56978bc057"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742",
- "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742",
+ "url": "https://api.github.com/repos/illuminate/macroable/zipball/59b5b5f3cf290a91db8cf6cd3d35ff56978bc057",
+ "reference": "59b5b5f3cf290a91db8cf6cd3d35ff56978bc057",
"shasum": ""
},
"require": {
- "ext-filter": "*",
- "php": "^8.1",
- "psr/http-factory": "^1",
- "psr/http-message": "^1.1 || ^2.0"
- },
- "suggest": {
- "ext-bcmath": "to improve IPV4 host parsing",
- "ext-gmp": "to improve IPV4 host parsing",
- "ext-intl": "to handle IDN host with the best performance",
- "php-64bit": "to improve IPV4 host parsing",
- "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
+ "php": "^8.2"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "7.x-dev"
+ "dev-master": "13.0.x-dev"
}
},
"autoload": {
"psr-4": {
- "League\\Uri\\": ""
+ "Illuminate\\Support\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -1246,45 +194,17 @@
],
"authors": [
{
- "name": "Ignace Nyamagana Butera",
- "email": "nyamsprod@gmail.com",
- "homepage": "https://nyamsprod.com"
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
}
],
- "description": "Common interfaces and classes for URI representation and interaction",
- "homepage": "https://uri.thephpleague.com",
- "keywords": [
- "data-uri",
- "file-uri",
- "ftp",
- "hostname",
- "http",
- "https",
- "parse_str",
- "parse_url",
- "psr-7",
- "query-string",
- "querystring",
- "rfc3986",
- "rfc3987",
- "rfc6570",
- "uri",
- "url",
- "ws"
- ],
+ "description": "The Illuminate Macroable package.",
+ "homepage": "https://laravel.com",
"support": {
- "docs": "https://uri.thephpleague.com",
- "forum": "https://thephpleague.slack.com",
- "issues": "https://github.com/thephpleague/uri-src/issues",
- "source": "https://github.com/thephpleague/uri-interfaces/tree/7.5.0"
+ "issues": "https://github.com/laravel/framework/issues",
+ "source": "https://github.com/laravel/framework"
},
- "funding": [
- {
- "url": "https://github.com/sponsors/nyamsprod",
- "type": "github"
- }
- ],
- "time": "2024-12-08T08:18:47+00:00"
+ "time": "2026-04-29T09:35:06+00:00"
},
{
"name": "psr/container",
@@ -1340,32 +260,31 @@
"time": "2021-11-05T16:47:00+00:00"
},
{
- "name": "psr/http-factory",
- "version": "1.1.0",
+ "name": "psr/simple-cache",
+ "version": "3.0.0",
"source": {
"type": "git",
- "url": "https://github.com/php-fig/http-factory.git",
- "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
+ "url": "https://github.com/php-fig/simple-cache.git",
+ "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
- "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
+ "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
"shasum": ""
},
"require": {
- "php": ">=7.1",
- "psr/http-message": "^1.0 || ^2.0"
+ "php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "1.0.x-dev"
+ "dev-master": "3.0.x-dev"
}
},
"autoload": {
"psr-4": {
- "Psr\\Http\\Message\\": "src/"
+ "Psr\\SimpleCache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -1378,48 +297,49 @@
"homepage": "https://www.php-fig.org/"
}
],
- "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
+ "description": "Common interfaces for simple caching",
"keywords": [
- "factory",
- "http",
- "message",
+ "cache",
+ "caching",
"psr",
- "psr-17",
- "psr-7",
- "request",
- "response"
+ "psr-16",
+ "simple-cache"
],
"support": {
- "source": "https://github.com/php-fig/http-factory"
+ "source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
},
- "time": "2024-04-15T12:06:14+00:00"
+ "time": "2021-10-29T13:26:27+00:00"
},
{
- "name": "psr/http-message",
- "version": "2.0",
+ "name": "spatie/laravel-package-tools",
+ "version": "1.93.1",
"source": {
"type": "git",
- "url": "https://github.com/php-fig/http-message.git",
- "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
+ "url": "https://github.com/spatie/laravel-package-tools.git",
+ "reference": "d5552849801f2642aea710557463234b59ef65eb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
- "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d5552849801f2642aea710557463234b59ef65eb",
+ "reference": "d5552849801f2642aea710557463234b59ef65eb",
"shasum": ""
},
"require": {
- "php": "^7.2 || ^8.0"
+ "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0",
+ "php": "^8.1"
},
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "2.0.x-dev"
- }
+ "require-dev": {
+ "mockery/mockery": "^1.5",
+ "orchestra/testbench": "^8.0|^9.2|^10.0|^11.0",
+ "pestphp/pest": "^2.1|^3.1|^4.0",
+ "phpunit/php-code-coverage": "^10.0|^11.0|^12.0",
+ "phpunit/phpunit": "^10.5|^11.5|^12.5",
+ "spatie/pest-plugin-test-time": "^2.2|^3.0"
},
+ "type": "library",
"autoload": {
"psr-4": {
- "Psr\\Http\\Message\\": "src/"
+ "Spatie\\LaravelPackageTools\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -1428,51 +348,75 @@
],
"authors": [
{
- "name": "PHP-FIG",
- "homepage": "https://www.php-fig.org/"
+ "name": "Freek Van der Herten",
+ "email": "freek@spatie.be",
+ "role": "Developer"
}
],
- "description": "Common interface for HTTP messages",
- "homepage": "https://github.com/php-fig/http-message",
+ "description": "Tools for creating Laravel packages",
+ "homepage": "https://github.com/spatie/laravel-package-tools",
"keywords": [
- "http",
- "http-message",
- "psr",
- "psr-7",
- "request",
- "response"
+ "laravel-package-tools",
+ "spatie"
],
"support": {
- "source": "https://github.com/php-fig/http-message/tree/2.0"
+ "issues": "https://github.com/spatie/laravel-package-tools/issues",
+ "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.1"
},
- "time": "2023-04-04T09:54:51+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/spatie",
+ "type": "github"
+ }
+ ],
+ "time": "2026-05-19T14:06:37+00:00"
},
{
- "name": "psr/simple-cache",
- "version": "3.0.0",
+ "name": "spatie/php-structure-discoverer",
+ "version": "2.4.4",
"source": {
"type": "git",
- "url": "https://github.com/php-fig/simple-cache.git",
- "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
+ "url": "https://github.com/spatie/php-structure-discoverer.git",
+ "reference": "fa2b7dae8e8a22c0306154c4b052420e054f7e2b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
- "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
+ "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/fa2b7dae8e8a22c0306154c4b052420e054f7e2b",
+ "reference": "fa2b7dae8e8a22c0306154c4b052420e054f7e2b",
"shasum": ""
},
"require": {
- "php": ">=8.0.0"
+ "illuminate/collections": "^11.0|^12.0|^13.0",
+ "php": "^8.3",
+ "spatie/laravel-package-tools": "^1.92.7",
+ "symfony/finder": "^6.0|^7.3.5|^8.0"
+ },
+ "require-dev": {
+ "amphp/parallel": "^2.3.2",
+ "illuminate/console": "^11.0|^12.0|^13.0",
+ "nunomaduro/collision": "^7.0|^8.8.3",
+ "orchestra/testbench": "^9.5|^10.8|^11.0",
+ "pestphp/pest": "^3.8|^4.0",
+ "pestphp/pest-plugin-laravel": "^3.2|^4.0",
+ "phpstan/extension-installer": "^1.4.3",
+ "phpstan/phpstan-deprecation-rules": "^1.2.1",
+ "phpstan/phpstan-phpunit": "^1.4.2",
+ "spatie/laravel-ray": "^1.43.1"
+ },
+ "suggest": {
+ "amphp/parallel": "When you want to use the Parallel discover worker"
},
"type": "library",
"extra": {
- "branch-alias": {
- "dev-master": "3.0.x-dev"
+ "laravel": {
+ "providers": [
+ "Spatie\\StructureDiscoverer\\StructureDiscovererServiceProvider"
+ ]
}
},
"autoload": {
"psr-4": {
- "Psr\\SimpleCache\\": "src/"
+ "Spatie\\StructureDiscoverer\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -1481,56 +425,59 @@
],
"authors": [
{
- "name": "PHP-FIG",
- "homepage": "https://www.php-fig.org/"
+ "name": "Ruben Van Assche",
+ "email": "ruben@spatie.be",
+ "role": "Developer"
}
],
- "description": "Common interfaces for simple caching",
+ "description": "Automatically discover structures within your PHP application",
+ "homepage": "https://github.com/spatie/php-structure-discoverer",
"keywords": [
- "cache",
- "caching",
- "psr",
- "psr-16",
- "simple-cache"
+ "discover",
+ "laravel",
+ "php",
+ "php-structure-discoverer"
],
"support": {
- "source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
+ "issues": "https://github.com/spatie/php-structure-discoverer/issues",
+ "source": "https://github.com/spatie/php-structure-discoverer/tree/2.4.4"
},
- "time": "2021-10-29T13:26:27+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/LaravelAutoDiscoverer",
+ "type": "github"
+ }
+ ],
+ "time": "2026-06-15T07:14:32+00:00"
},
{
- "name": "revolt/event-loop",
- "version": "v1.0.7",
+ "name": "symfony/finder",
+ "version": "v7.4.14",
"source": {
"type": "git",
- "url": "https://github.com/revoltphp/event-loop.git",
- "reference": "09bf1bf7f7f574453efe43044b06fafe12216eb3"
+ "url": "https://github.com/symfony/finder.git",
+ "reference": "13b38720174286f55d1761152b575a8d1436fc25"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/09bf1bf7f7f574453efe43044b06fafe12216eb3",
- "reference": "09bf1bf7f7f574453efe43044b06fafe12216eb3",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25",
+ "reference": "13b38720174286f55d1761152b575a8d1436fc25",
"shasum": ""
},
"require": {
- "php": ">=8.1"
+ "php": ">=8.2"
},
"require-dev": {
- "ext-json": "*",
- "jetbrains/phpstorm-stubs": "^2019.3",
- "phpunit/phpunit": "^9",
- "psalm/phar": "^5.15"
+ "symfony/filesystem": "^6.4|^7.0|^8.0"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "1.x-dev"
- }
- },
"autoload": {
"psr-4": {
- "Revolt\\": "src"
- }
+ "Symfony\\Component\\Finder\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
@@ -1538,68 +485,73 @@
],
"authors": [
{
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Finds files and directories via an intuitive fluent interface",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/finder/tree/v7.4.14"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
},
{
- "name": "Cees-Jan Kiewiet",
- "email": "ceesjank@gmail.com"
+ "url": "https://github.com/fabpot",
+ "type": "github"
},
{
- "name": "Christian Lück",
- "email": "christian@clue.engineering"
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
},
{
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
}
],
- "description": "Rock-solid event loop for concurrent PHP applications.",
- "keywords": [
- "async",
- "asynchronous",
- "concurrency",
- "event",
- "event-loop",
- "non-blocking",
- "scheduler"
- ],
- "support": {
- "issues": "https://github.com/revoltphp/event-loop/issues",
- "source": "https://github.com/revoltphp/event-loop/tree/v1.0.7"
- },
- "time": "2025-01-25T19:27:39+00:00"
+ "time": "2026-06-27T08:31:18+00:00"
},
{
- "name": "spatie/laravel-package-tools",
- "version": "1.19.0",
+ "name": "symfony/polyfill-php84",
+ "version": "v1.38.1",
"source": {
"type": "git",
- "url": "https://github.com/spatie/laravel-package-tools.git",
- "reference": "1c9c30ac6a6576b8d15c6c37b6cf23d748df2faa"
+ "url": "https://github.com/symfony/polyfill-php84.git",
+ "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/1c9c30ac6a6576b8d15c6c37b6cf23d748df2faa",
- "reference": "1c9c30ac6a6576b8d15c6c37b6cf23d748df2faa",
+ "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa",
+ "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa",
"shasum": ""
},
"require": {
- "illuminate/contracts": "^9.28|^10.0|^11.0|^12.0",
- "php": "^8.0"
- },
- "require-dev": {
- "mockery/mockery": "^1.5",
- "orchestra/testbench": "^7.7|^8.0|^9.0|^10.0",
- "pestphp/pest": "^1.23|^2.1|^3.1",
- "phpunit/phpunit": "^9.5.24|^10.5|^11.5",
- "spatie/pest-plugin-test-time": "^1.1|^2.2"
+ "php": ">=7.2"
},
"type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
"autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
"psr-4": {
- "Spatie\\LaravelPackageTools\\": "src"
- }
+ "Symfony\\Polyfill\\Php84\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
@@ -1607,76 +559,79 @@
],
"authors": [
{
- "name": "Freek Van der Herten",
- "email": "freek@spatie.be",
- "role": "Developer"
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
}
],
- "description": "Tools for creating Laravel packages",
- "homepage": "https://github.com/spatie/laravel-package-tools",
+ "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
"keywords": [
- "laravel-package-tools",
- "spatie"
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
],
"support": {
- "issues": "https://github.com/spatie/laravel-package-tools/issues",
- "source": "https://github.com/spatie/laravel-package-tools/tree/1.19.0"
+ "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1"
},
"funding": [
{
- "url": "https://github.com/spatie",
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
"type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
}
],
- "time": "2025-02-06T14:58:20+00:00"
+ "time": "2026-05-26T12:51:13+00:00"
},
{
- "name": "spatie/php-structure-discoverer",
- "version": "2.3.1",
+ "name": "symfony/polyfill-php85",
+ "version": "v1.41.0",
"source": {
"type": "git",
- "url": "https://github.com/spatie/php-structure-discoverer.git",
- "reference": "42f4d731d3dd4b3b85732e05a8c1928fcfa2f4bc"
+ "url": "https://github.com/symfony/polyfill-php85.git",
+ "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/42f4d731d3dd4b3b85732e05a8c1928fcfa2f4bc",
- "reference": "42f4d731d3dd4b3b85732e05a8c1928fcfa2f4bc",
+ "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a",
+ "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a",
"shasum": ""
},
"require": {
- "amphp/amp": "^v3.0",
- "amphp/parallel": "^2.2",
- "illuminate/collections": "^10.0|^11.0|^12.0",
- "php": "^8.1",
- "spatie/laravel-package-tools": "^1.4.3",
- "symfony/finder": "^6.0|^7.0"
- },
- "require-dev": {
- "illuminate/console": "^10.0|^11.0|^12.0",
- "laravel/pint": "^1.0",
- "nunomaduro/collision": "^7.0|^8.0",
- "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0",
- "pestphp/pest": "^2.0|^3.0",
- "pestphp/pest-plugin-laravel": "^2.0|^3.0",
- "phpstan/extension-installer": "^1.1",
- "phpstan/phpstan-deprecation-rules": "^1.0",
- "phpstan/phpstan-phpunit": "^1.0",
- "phpunit/phpunit": "^9.5|^10.0|^11.5.3",
- "spatie/laravel-ray": "^1.26"
+ "php": ">=7.2"
},
"type": "library",
"extra": {
- "laravel": {
- "providers": [
- "Spatie\\StructureDiscoverer\\StructureDiscovererServiceProvider"
- ]
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
}
},
"autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
"psr-4": {
- "Spatie\\StructureDiscoverer\\": "src"
- }
+ "Symfony\\Polyfill\\Php85\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
@@ -1684,58 +639,78 @@
],
"authors": [
{
- "name": "Ruben Van Assche",
- "email": "ruben@spatie.be",
- "role": "Developer"
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
}
],
- "description": "Automatically discover structures within your PHP application",
- "homepage": "https://github.com/spatie/php-structure-discoverer",
+ "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
"keywords": [
- "discover",
- "laravel",
- "php",
- "php-structure-discoverer"
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
],
"support": {
- "issues": "https://github.com/spatie/php-structure-discoverer/issues",
- "source": "https://github.com/spatie/php-structure-discoverer/tree/2.3.1"
+ "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0"
},
"funding": [
{
- "url": "https://github.com/LaravelAutoDiscoverer",
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
"type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
}
],
- "time": "2025-02-14T10:18:38+00:00"
+ "time": "2026-07-01T12:47:55+00:00"
},
{
- "name": "symfony/finder",
- "version": "v7.2.2",
+ "name": "symfony/polyfill-php86",
+ "version": "v1.41.0",
"source": {
"type": "git",
- "url": "https://github.com/symfony/finder.git",
- "reference": "87a71856f2f56e4100373e92529eed3171695cfb"
+ "url": "https://github.com/symfony/polyfill-php86.git",
+ "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/finder/zipball/87a71856f2f56e4100373e92529eed3171695cfb",
- "reference": "87a71856f2f56e4100373e92529eed3171695cfb",
+ "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/6bc356ed3d8dbfeea8f0de235e34d670704e880e",
+ "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e",
"shasum": ""
},
"require": {
- "php": ">=8.2"
- },
- "require-dev": {
- "symfony/filesystem": "^6.4|^7.0"
+ "php": ">=7.2"
},
"type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
"autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
"psr-4": {
- "Symfony\\Component\\Finder\\": ""
+ "Symfony\\Polyfill\\Php86\\": ""
},
- "exclude-from-classmap": [
- "/Tests/"
+ "classmap": [
+ "Resources/stubs"
]
},
"notification-url": "https://packagist.org/downloads/",
@@ -1744,18 +719,24 @@
],
"authors": [
{
- "name": "Fabien Potencier",
- "email": "fabien@symfony.com"
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Finds files and directories via an intuitive fluent interface",
+ "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions",
"homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
"support": {
- "source": "https://github.com/symfony/finder/tree/v7.2.2"
+ "source": "https://github.com/symfony/polyfill-php86/tree/v1.41.0"
},
"funding": [
{
@@ -1766,27 +747,31 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-12-30T19:00:17+00:00"
+ "time": "2026-07-02T13:42:24+00:00"
}
],
"packages-dev": [
{
"name": "10up/phpcs-composer",
- "version": "3.0.0",
+ "version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/10up/phpcs-composer.git",
- "reference": "04fe5f0d61948f9e38e9cd037a5ee50dcdbf4688"
+ "reference": "ed5f6d4e7dc090338cc86b5925fb961b21e4e4d4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/10up/phpcs-composer/zipball/04fe5f0d61948f9e38e9cd037a5ee50dcdbf4688",
- "reference": "04fe5f0d61948f9e38e9cd037a5ee50dcdbf4688",
+ "url": "https://api.github.com/repos/10up/phpcs-composer/zipball/ed5f6d4e7dc090338cc86b5925fb961b21e4e4d4",
+ "reference": "ed5f6d4e7dc090338cc86b5925fb961b21e4e4d4",
"shasum": ""
},
"require": {
@@ -1795,7 +780,7 @@
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "*",
- "phpcompatibility/php-compatibility": "dev-develop as 9.99.99"
+ "phpcompatibility/php-compatibility": "10.0.0-alpha2 as 9.99.99"
},
"type": "phpcodesniffer-standard",
"notification-url": "https://packagist.org/downloads/",
@@ -1811,22 +796,22 @@
"description": "10up's PHP CodeSniffer Ruleset",
"support": {
"issues": "https://github.com/10up/phpcs-composer/issues",
- "source": "https://github.com/10up/phpcs-composer/tree/3.0.0"
+ "source": "https://github.com/10up/phpcs-composer/tree/3.0.1"
},
- "time": "2023-12-14T15:37:22+00:00"
+ "time": "2025-12-09T17:31:17+00:00"
},
{
"name": "antecedent/patchwork",
- "version": "2.2.1",
+ "version": "2.2.3",
"source": {
"type": "git",
"url": "https://github.com/antecedent/patchwork.git",
- "reference": "1bf183a3e1bd094f231a2128b9ecc5363c269245"
+ "reference": "8b6b235f405af175259c8f56aea5fc23ab9f03ce"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/antecedent/patchwork/zipball/1bf183a3e1bd094f231a2128b9ecc5363c269245",
- "reference": "1bf183a3e1bd094f231a2128b9ecc5363c269245",
+ "url": "https://api.github.com/repos/antecedent/patchwork/zipball/8b6b235f405af175259c8f56aea5fc23ab9f03ce",
+ "reference": "8b6b235f405af175259c8f56aea5fc23ab9f03ce",
"shasum": ""
},
"require": {
@@ -1859,38 +844,38 @@
],
"support": {
"issues": "https://github.com/antecedent/patchwork/issues",
- "source": "https://github.com/antecedent/patchwork/tree/2.2.1"
+ "source": "https://github.com/antecedent/patchwork/tree/2.2.3"
},
- "time": "2024-12-11T10:19:54+00:00"
+ "time": "2025-09-17T09:00:56+00:00"
},
{
"name": "automattic/vipwpcs",
- "version": "3.0.1",
+ "version": "3.1.0",
"source": {
"type": "git",
"url": "https://github.com/Automattic/VIP-Coding-Standards.git",
- "reference": "2b1d206d81b74ed999023cffd924f862ff2753c8"
+ "reference": "9c47cd036754e0e5f354a9914568f052043c3f30"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Automattic/VIP-Coding-Standards/zipball/2b1d206d81b74ed999023cffd924f862ff2753c8",
- "reference": "2b1d206d81b74ed999023cffd924f862ff2753c8",
+ "url": "https://api.github.com/repos/Automattic/VIP-Coding-Standards/zipball/9c47cd036754e0e5f354a9914568f052043c3f30",
+ "reference": "9c47cd036754e0e5f354a9914568f052043c3f30",
"shasum": ""
},
"require": {
- "php": ">=5.4",
- "phpcsstandards/phpcsextra": "^1.2.1",
- "phpcsstandards/phpcsutils": "^1.0.11",
- "sirbrillig/phpcs-variable-analysis": "^2.11.18",
- "squizlabs/php_codesniffer": "^3.9.2",
- "wp-coding-standards/wpcs": "^3.1.0"
+ "php": ">=7.4",
+ "phpcsstandards/phpcsextra": "^1.5.1",
+ "phpcsstandards/phpcsutils": "^1.2.3",
+ "sirbrillig/phpcs-variable-analysis": "^2.13.0",
+ "squizlabs/php_codesniffer": "^3.13.5",
+ "wp-coding-standards/wpcs": "^3.4.1"
},
"require-dev": {
"php-parallel-lint/php-console-highlighter": "^1.0.0",
- "php-parallel-lint/php-parallel-lint": "^1.3.2",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
"phpcompatibility/php-compatibility": "^9",
- "phpcsstandards/phpcsdevtools": "^1.0",
- "phpunit/phpunit": "^4 || ^5 || ^6 || ^7 || ^8 || ^9"
+ "phpcsstandards/phpcsdevtools": "^1.2.3",
+ "phpunit/phpunit": "^9"
},
"type": "phpcodesniffer-standard",
"notification-url": "https://packagist.org/downloads/",
@@ -1915,31 +900,31 @@
"source": "https://github.com/Automattic/VIP-Coding-Standards",
"wiki": "https://github.com/Automattic/VIP-Coding-Standards/wiki"
},
- "time": "2024-05-10T20:31:09+00:00"
+ "time": "2026-07-27T14:33:48+00:00"
},
{
"name": "brain/monkey",
- "version": "2.6.2",
+ "version": "2.7.0",
"source": {
"type": "git",
"url": "https://github.com/Brain-WP/BrainMonkey.git",
- "reference": "d95a9d895352c30f47604ad1b825ab8fa9d1a373"
+ "reference": "ea3aeb3d559ba3c0930b3f4d210b665a4c044d83"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Brain-WP/BrainMonkey/zipball/d95a9d895352c30f47604ad1b825ab8fa9d1a373",
- "reference": "d95a9d895352c30f47604ad1b825ab8fa9d1a373",
+ "url": "https://api.github.com/repos/Brain-WP/BrainMonkey/zipball/ea3aeb3d559ba3c0930b3f4d210b665a4c044d83",
+ "reference": "ea3aeb3d559ba3c0930b3f4d210b665a4c044d83",
"shasum": ""
},
"require": {
"antecedent/patchwork": "^2.1.17",
- "mockery/mockery": "^1.3.5 || ^1.4.4",
+ "mockery/mockery": "~1.3.6 || ~1.4.4 || ~1.5.1 || ^1.6.10",
"php": ">=5.6.0"
},
"require-dev": {
- "dealerdirect/phpcodesniffer-composer-installer": "^0.7.1",
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0",
"phpcompatibility/php-compatibility": "^9.3.0",
- "phpunit/phpunit": "^5.7.26 || ^6.0 || ^7.0 || >=8.0 <8.5.12 || ^8.5.14 || ^9.0"
+ "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.49 || ^9.6.30"
},
"type": "library",
"extra": {
@@ -1985,33 +970,33 @@
"issues": "https://github.com/Brain-WP/BrainMonkey/issues",
"source": "https://github.com/Brain-WP/BrainMonkey"
},
- "time": "2024-08-29T20:15:04+00:00"
+ "time": "2026-02-05T09:22:14+00:00"
},
{
"name": "dealerdirect/phpcodesniffer-composer-installer",
- "version": "v1.0.0",
+ "version": "v1.2.1",
"source": {
"type": "git",
"url": "https://github.com/PHPCSStandards/composer-installer.git",
- "reference": "4be43904336affa5c2f70744a348312336afd0da"
+ "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/4be43904336affa5c2f70744a348312336afd0da",
- "reference": "4be43904336affa5c2f70744a348312336afd0da",
+ "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd",
+ "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd",
"shasum": ""
},
"require": {
- "composer-plugin-api": "^1.0 || ^2.0",
+ "composer-plugin-api": "^2.2",
"php": ">=5.4",
- "squizlabs/php_codesniffer": "^2.0 || ^3.1.0 || ^4.0"
+ "squizlabs/php_codesniffer": "^3.1.0 || ^4.0"
},
"require-dev": {
- "composer/composer": "*",
+ "composer/composer": "^2.2",
"ext-json": "*",
"ext-zip": "*",
- "php-parallel-lint/php-parallel-lint": "^1.3.1",
- "phpcompatibility/php-compatibility": "^9.0",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev",
"yoast/phpunit-polyfills": "^1.0"
},
"type": "composer-plugin",
@@ -2030,9 +1015,9 @@
"authors": [
{
"name": "Franck Nijhof",
- "email": "franck.nijhof@dealerdirect.com",
- "homepage": "http://www.frenck.nl",
- "role": "Developer / IT Manager"
+ "email": "opensource@frenck.dev",
+ "homepage": "https://frenck.dev",
+ "role": "Open source developer"
},
{
"name": "Contributors",
@@ -2040,7 +1025,6 @@
}
],
"description": "PHP_CodeSniffer Standards Composer Installer Plugin",
- "homepage": "http://www.dealerdirect.com",
"keywords": [
"PHPCodeSniffer",
"PHP_CodeSniffer",
@@ -2061,9 +1045,28 @@
],
"support": {
"issues": "https://github.com/PHPCSStandards/composer-installer/issues",
+ "security": "https://github.com/PHPCSStandards/composer-installer/security/policy",
"source": "https://github.com/PHPCSStandards/composer-installer"
},
- "time": "2023-01-05T11:28:13+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/PHPCSStandards",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcsstandards",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2026-05-06T08:26:05+00:00"
},
{
"name": "doctrine/instantiator",
@@ -2137,20 +1140,20 @@
},
{
"name": "hamcrest/hamcrest-php",
- "version": "v2.0.1",
+ "version": "v2.1.1",
"source": {
"type": "git",
"url": "https://github.com/hamcrest/hamcrest-php.git",
- "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3"
+ "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/8c3d0a3f6af734494ad8f6fbbee0ba92422859f3",
- "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3",
+ "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487",
+ "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487",
"shasum": ""
},
"require": {
- "php": "^5.3|^7.0|^8.0"
+ "php": "^7.4|^8.0"
},
"replace": {
"cordoval/hamcrest-php": "*",
@@ -2158,8 +1161,8 @@
"kodova/hamcrest-php": "*"
},
"require-dev": {
- "phpunit/php-file-iterator": "^1.4 || ^2.0",
- "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0"
+ "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0",
+ "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0"
},
"type": "library",
"extra": {
@@ -2182,9 +1185,9 @@
],
"support": {
"issues": "https://github.com/hamcrest/hamcrest-php/issues",
- "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.0.1"
+ "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1"
},
- "time": "2020-07-09T08:09:16+00:00"
+ "time": "2025-04-30T06:54:44+00:00"
},
{
"name": "mockery/mockery",
@@ -2329,22 +1332,112 @@
],
"time": "2025-08-01T08:46:24+00:00"
},
+ {
+ "name": "nette/utils",
+ "version": "v4.1.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nette/utils.git",
+ "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0",
+ "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0",
+ "shasum": ""
+ },
+ "require": {
+ "php": "8.2 - 8.5"
+ },
+ "conflict": {
+ "nette/finder": "<3",
+ "nette/schema": "<1.2.2"
+ },
+ "require-dev": {
+ "jetbrains/phpstorm-attributes": "^1.2",
+ "nette/phpstan-rules": "^1.0",
+ "nette/tester": "^2.5",
+ "phpstan/extension-installer": "^1.4@stable",
+ "phpstan/phpstan": "^2.1@stable",
+ "tracy/tracy": "^2.9"
+ },
+ "suggest": {
+ "ext-gd": "to use Image",
+ "ext-iconv": "to use Strings::chr(), ord() and reverse()",
+ "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()",
+ "ext-json": "to use Nette\\Utils\\Json",
+ "ext-mbstring": "to use Strings::lower() etc...",
+ "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.1-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Nette\\": "src"
+ },
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause",
+ "GPL-2.0-only",
+ "GPL-3.0-only"
+ ],
+ "authors": [
+ {
+ "name": "David Grudl",
+ "homepage": "https://davidgrudl.com"
+ },
+ {
+ "name": "Nette Community",
+ "homepage": "https://nette.org/contributors"
+ }
+ ],
+ "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.",
+ "homepage": "https://nette.org",
+ "keywords": [
+ "array",
+ "core",
+ "datetime",
+ "images",
+ "json",
+ "nette",
+ "paginator",
+ "password",
+ "slugify",
+ "string",
+ "unicode",
+ "utf-8",
+ "utility",
+ "validation"
+ ],
+ "support": {
+ "issues": "https://github.com/nette/utils/issues",
+ "source": "https://github.com/nette/utils/tree/v4.1.5"
+ },
+ "time": "2026-07-17T23:02:45+00:00"
+ },
{
"name": "nikic/php-parser",
- "version": "v5.7.0",
+ "version": "v5.8.0",
"source": {
"type": "git",
"url": "https://github.com/nikic/PHP-Parser.git",
- "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
+ "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
- "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
+ "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
"shasum": ""
},
"require": {
- "ext-ctype": "*",
"ext-json": "*",
"ext-tokenizer": "*",
"php": ">=7.4"
@@ -2383,9 +1476,9 @@
],
"support": {
"issues": "https://github.com/nikic/PHP-Parser/issues",
- "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0"
},
- "time": "2025-12-06T11:56:16+00:00"
+ "time": "2026-07-04T14:30:18+00:00"
},
{
"name": "phar-io/manifest",
@@ -2507,26 +1600,30 @@
},
{
"name": "php-stubs/wordpress-stubs",
- "version": "v6.7.2",
+ "version": "v6.9.4",
"source": {
"type": "git",
"url": "https://github.com/php-stubs/wordpress-stubs.git",
- "reference": "c04f96cb232fab12a3cbcccf5a47767f0665c3f4"
+ "reference": "90a9412826b9944f93b10bf41d795b5fe68abcd5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/c04f96cb232fab12a3cbcccf5a47767f0665c3f4",
- "reference": "c04f96cb232fab12a3cbcccf5a47767f0665c3f4",
+ "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/90a9412826b9944f93b10bf41d795b5fe68abcd5",
+ "reference": "90a9412826b9944f93b10bf41d795b5fe68abcd5",
"shasum": ""
},
+ "conflict": {
+ "phpdocumentor/reflection-docblock": "5.6.1"
+ },
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
- "nikic/php-parser": "^4.13",
+ "nikic/php-parser": "^5.5",
"php": "^7.4 || ^8.0",
- "php-stubs/generator": "^0.8.3",
- "phpdocumentor/reflection-docblock": "^5.4.1",
- "phpstan/phpstan": "^1.11",
+ "php-stubs/generator": "^0.8.6",
+ "phpdocumentor/reflection-docblock": "^6.0",
+ "phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^9.5",
+ "symfony/polyfill-php80": "*",
"szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.1.1",
"wp-coding-standards/wpcs": "3.1.0 as 2.3.0"
},
@@ -2549,22 +1646,22 @@
],
"support": {
"issues": "https://github.com/php-stubs/wordpress-stubs/issues",
- "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.7.2"
+ "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.9.4"
},
- "time": "2025-02-12T04:51:58+00:00"
+ "time": "2026-05-01T20:36:01+00:00"
},
{
"name": "php-stubs/wp-cli-stubs",
- "version": "v2.11.0",
+ "version": "v2.12.0",
"source": {
"type": "git",
"url": "https://github.com/php-stubs/wp-cli-stubs.git",
- "reference": "f27ff9e8e29d7962cb070e58de70dfaf63183007"
+ "reference": "af16401e299a3fd2229bd0fa9a037638a4174a9d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-stubs/wp-cli-stubs/zipball/f27ff9e8e29d7962cb070e58de70dfaf63183007",
- "reference": "f27ff9e8e29d7962cb070e58de70dfaf63183007",
+ "url": "https://api.github.com/repos/php-stubs/wp-cli-stubs/zipball/af16401e299a3fd2229bd0fa9a037638a4174a9d",
+ "reference": "af16401e299a3fd2229bd0fa9a037638a4174a9d",
"shasum": ""
},
"require": {
@@ -2593,51 +1690,39 @@
],
"support": {
"issues": "https://github.com/php-stubs/wp-cli-stubs/issues",
- "source": "https://github.com/php-stubs/wp-cli-stubs/tree/v2.11.0"
+ "source": "https://github.com/php-stubs/wp-cli-stubs/tree/v2.12.0"
},
- "time": "2024-11-25T10:09:13+00:00"
+ "time": "2025-06-10T09:58:05+00:00"
},
{
"name": "phpcompatibility/php-compatibility",
- "version": "dev-develop",
+ "version": "9.3.5",
"source": {
"type": "git",
"url": "https://github.com/PHPCompatibility/PHPCompatibility.git",
- "reference": "9013cd039fe5740953f9fdeebd19d901b80e26f2"
+ "reference": "9fb324479acf6f39452e0655d2429cc0d3914243"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/9013cd039fe5740953f9fdeebd19d901b80e26f2",
- "reference": "9013cd039fe5740953f9fdeebd19d901b80e26f2",
+ "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/9fb324479acf6f39452e0655d2429cc0d3914243",
+ "reference": "9fb324479acf6f39452e0655d2429cc0d3914243",
"shasum": ""
},
"require": {
- "php": ">=5.4",
- "phpcsstandards/phpcsutils": "^1.0.12",
- "squizlabs/php_codesniffer": "^3.10.0"
+ "php": ">=5.3",
+ "squizlabs/php_codesniffer": "^2.3 || ^3.0.2"
},
- "replace": {
- "wimg/php-compatibility": "*"
+ "conflict": {
+ "squizlabs/php_codesniffer": "2.6.2"
},
"require-dev": {
- "php-parallel-lint/php-console-highlighter": "^1.0.0",
- "php-parallel-lint/php-parallel-lint": "^1.4.0",
- "phpcsstandards/phpcsdevcs": "^1.1.3",
- "phpcsstandards/phpcsdevtools": "^1.2.0",
- "phpunit/phpunit": "^4.8.36 || ^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4 || ^10.1.0",
- "yoast/phpunit-polyfills": "^1.0.5 || ^2.0.0"
+ "phpunit/phpunit": "~4.5 || ^5.0 || ^6.0 || ^7.0"
},
"suggest": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^0.5 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically.",
"roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues."
},
- "default-branch": true,
"type": "phpcodesniffer-standard",
- "extra": {
- "branch-alias": {
- "dev-master": "9.x-dev",
- "dev-develop": "10.x-dev"
- }
- },
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
@@ -2663,46 +1748,26 @@
"keywords": [
"compatibility",
"phpcs",
- "standards",
- "static analysis"
+ "standards"
],
"support": {
"issues": "https://github.com/PHPCompatibility/PHPCompatibility/issues",
- "security": "https://github.com/PHPCompatibility/PHPCompatibility/security/policy",
"source": "https://github.com/PHPCompatibility/PHPCompatibility"
},
- "funding": [
- {
- "url": "https://github.com/PHPCompatibility",
- "type": "github"
- },
- {
- "url": "https://github.com/jrfnl",
- "type": "github"
- },
- {
- "url": "https://opencollective.com/php_codesniffer",
- "type": "open_collective"
- },
- {
- "url": "https://thanks.dev/u/gh/phpcompatibility",
- "type": "thanks_dev"
- }
- ],
- "time": "2025-01-20T20:06:48+00:00"
+ "time": "2019-12-27T09:44:58+00:00"
},
{
"name": "phpcompatibility/phpcompatibility-paragonie",
- "version": "1.3.3",
+ "version": "1.3.4",
"source": {
"type": "git",
"url": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie.git",
- "reference": "293975b465e0e709b571cbf0c957c6c0a7b9a2ac"
+ "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/293975b465e0e709b571cbf0c957c6c0a7b9a2ac",
- "reference": "293975b465e0e709b571cbf0c957c6c0a7b9a2ac",
+ "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/244d7b04fc4bc2117c15f5abe23eb933b5f02bbf",
+ "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf",
"shasum": ""
},
"require": {
@@ -2759,27 +1824,32 @@
{
"url": "https://opencollective.com/php_codesniffer",
"type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcompatibility",
+ "type": "thanks_dev"
}
],
- "time": "2024-04-24T21:30:46+00:00"
+ "time": "2025-09-19T17:43:28+00:00"
},
{
"name": "phpcompatibility/phpcompatibility-wp",
- "version": "2.1.6",
+ "version": "2.1.8",
"source": {
"type": "git",
"url": "https://github.com/PHPCompatibility/PHPCompatibilityWP.git",
- "reference": "80ccb1a7640995edf1b87a4409fa584cd5869469"
+ "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/80ccb1a7640995edf1b87a4409fa584cd5869469",
- "reference": "80ccb1a7640995edf1b87a4409fa584cd5869469",
+ "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/7c8d18b4d90dac9e86b0869a608fa09158e168fa",
+ "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa",
"shasum": ""
},
"require": {
"phpcompatibility/php-compatibility": "^9.0",
- "phpcompatibility/phpcompatibility-paragonie": "^1.0"
+ "phpcompatibility/phpcompatibility-paragonie": "^1.0",
+ "squizlabs/php_codesniffer": "^3.3"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.0"
@@ -2829,35 +1899,39 @@
{
"url": "https://opencollective.com/php_codesniffer",
"type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcompatibility",
+ "type": "thanks_dev"
}
],
- "time": "2025-01-16T22:34:19+00:00"
+ "time": "2025-10-18T00:05:59+00:00"
},
{
"name": "phpcsstandards/phpcsextra",
- "version": "1.2.1",
+ "version": "1.5.1",
"source": {
"type": "git",
"url": "https://github.com/PHPCSStandards/PHPCSExtra.git",
- "reference": "11d387c6642b6e4acaf0bd9bf5203b8cca1ec489"
+ "reference": "39467533fdb742446d68c1d10ac33d625ee0311c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/11d387c6642b6e4acaf0bd9bf5203b8cca1ec489",
- "reference": "11d387c6642b6e4acaf0bd9bf5203b8cca1ec489",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/39467533fdb742446d68c1d10ac33d625ee0311c",
+ "reference": "39467533fdb742446d68c1d10ac33d625ee0311c",
"shasum": ""
},
"require": {
"php": ">=5.4",
- "phpcsstandards/phpcsutils": "^1.0.9",
- "squizlabs/php_codesniffer": "^3.8.0"
+ "phpcsstandards/phpcsutils": "^1.2.3",
+ "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1"
},
"require-dev": {
"php-parallel-lint/php-console-highlighter": "^1.0",
- "php-parallel-lint/php-parallel-lint": "^1.3.2",
- "phpcsstandards/phpcsdevcs": "^1.1.6",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "phpcsstandards/phpcsdevcs": "^1.2.0",
"phpcsstandards/phpcsdevtools": "^1.2.1",
- "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0"
+ "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4"
},
"type": "phpcodesniffer-standard",
"extra": {
@@ -2907,35 +1981,39 @@
{
"url": "https://opencollective.com/php_codesniffer",
"type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcsstandards",
+ "type": "thanks_dev"
}
],
- "time": "2023-12-08T16:49:07+00:00"
+ "time": "2026-07-27T11:13:17+00:00"
},
{
"name": "phpcsstandards/phpcsutils",
- "version": "1.0.12",
+ "version": "1.2.3",
"source": {
"type": "git",
"url": "https://github.com/PHPCSStandards/PHPCSUtils.git",
- "reference": "87b233b00daf83fb70f40c9a28692be017ea7c6c"
+ "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/87b233b00daf83fb70f40c9a28692be017ea7c6c",
- "reference": "87b233b00daf83fb70f40c9a28692be017ea7c6c",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/5f35d9408c54d7b529501f3c688b6eae562aea1f",
+ "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f",
"shasum": ""
},
"require": {
"dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0",
"php": ">=5.4",
- "squizlabs/php_codesniffer": "^3.10.0 || 4.0.x-dev@dev"
+ "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1"
},
"require-dev": {
"ext-filter": "*",
"php-parallel-lint/php-console-highlighter": "^1.0",
- "php-parallel-lint/php-parallel-lint": "^1.3.2",
- "phpcsstandards/phpcsdevcs": "^1.1.6",
- "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0"
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "phpcsstandards/phpcsdevcs": "^1.2.0",
+ "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0"
},
"type": "phpcodesniffer-standard",
"extra": {
@@ -2972,6 +2050,7 @@
"phpcodesniffer-standard",
"phpcs",
"phpcs3",
+ "phpcs4",
"standards",
"static analysis",
"tokens",
@@ -2995,22 +2074,26 @@
{
"url": "https://opencollective.com/php_codesniffer",
"type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcsstandards",
+ "type": "thanks_dev"
}
],
- "time": "2024-05-20T13:34:27+00:00"
+ "time": "2026-07-27T10:28:41+00:00"
},
{
"name": "phpstan/phpdoc-parser",
- "version": "2.1.0",
+ "version": "2.3.3",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git",
- "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68"
+ "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/9b30d6fd026b2c132b3985ce6b23bec09ab3aa68",
- "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68",
+ "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3",
+ "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3",
"shasum": ""
},
"require": {
@@ -3042,22 +2125,17 @@
"description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
- "source": "https://github.com/phpstan/phpdoc-parser/tree/2.1.0"
+ "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3"
},
- "time": "2025-02-19T13:28:12+00:00"
+ "time": "2026-07-08T07:01:06+00:00"
},
{
"name": "phpstan/phpstan",
- "version": "2.1.8",
- "source": {
- "type": "git",
- "url": "https://github.com/phpstan/phpstan.git",
- "reference": "f9adff3b87c03b12cc7e46a30a524648e497758f"
- },
+ "version": "2.2.7",
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan/zipball/f9adff3b87c03b12cc7e46a30a524648e497758f",
- "reference": "f9adff3b87c03b12cc7e46a30a524648e497758f",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/692db47b9dddb0487934e5236e77d48594aef921",
+ "reference": "692db47b9dddb0487934e5236e77d48594aef921",
"shasum": ""
},
"require": {
@@ -3080,6 +2158,17 @@
"license": [
"MIT"
],
+ "authors": [
+ {
+ "name": "Ondřej Mirtes"
+ },
+ {
+ "name": "Markus Staab"
+ },
+ {
+ "name": "Vincent Langlet"
+ }
+ ],
"description": "PHPStan - PHP Static Analysis Tool",
"keywords": [
"dev",
@@ -3102,30 +2191,31 @@
"type": "github"
}
],
- "time": "2025-03-09T09:30:48+00:00"
+ "time": "2026-07-29T17:39:32+00:00"
},
{
"name": "phpstan/phpstan-deprecation-rules",
- "version": "2.0.1",
+ "version": "2.0.5",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-deprecation-rules.git",
- "reference": "1cc1259cb91ee4cfbb5c39bca9f635f067c910b4"
+ "reference": "67bedd65c24bc72840afc45aed48b1059dd44bec"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/1cc1259cb91ee4cfbb5c39bca9f635f067c910b4",
- "reference": "1cc1259cb91ee4cfbb5c39bca9f635f067c910b4",
+ "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/67bedd65c24bc72840afc45aed48b1059dd44bec",
+ "reference": "67bedd65c24bc72840afc45aed48b1059dd44bec",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0",
- "phpstan/phpstan": "^2.0"
+ "phpstan/phpstan": "^2.1.39"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.2",
"phpstan/phpstan-phpunit": "^2.0",
- "phpunit/phpunit": "^9.6"
+ "phpunit/phpunit": "^9.6",
+ "shipmonk/name-collision-detector": "^2.1"
},
"type": "phpstan-extension",
"extra": {
@@ -3145,11 +2235,14 @@
"MIT"
],
"description": "PHPStan rules for detecting usage of deprecated classes, methods, properties, constants and traits.",
+ "keywords": [
+ "static analysis"
+ ],
"support": {
"issues": "https://github.com/phpstan/phpstan-deprecation-rules/issues",
- "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/2.0.1"
+ "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/2.0.5"
},
- "time": "2024-11-28T21:56:36+00:00"
+ "time": "2026-07-22T06:50:43+00:00"
},
{
"name": "phpunit/php-code-coverage",
@@ -3472,25 +2565,25 @@
},
{
"name": "phpunit/phpunit",
- "version": "9.6.33",
+ "version": "9.6.35",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "fea06253ecc0a32faf787bd31b261f56f351d049"
+ "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fea06253ecc0a32faf787bd31b261f56f351d049",
- "reference": "fea06253ecc0a32faf787bd31b261f56f351d049",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0edba2f3a0c48df3553cb9b640810b30df60302b",
+ "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b",
"shasum": ""
},
"require": {
"doctrine/instantiator": "^1.5.0 || ^2",
"ext-dom": "*",
+ "ext-filter": "*",
"ext-json": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
- "ext-xml": "*",
"ext-xmlwriter": "*",
"myclabs/deep-copy": "^1.13.4",
"phar-io/manifest": "^2.0.4",
@@ -3555,31 +2648,75 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.33"
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.35"
},
"funding": [
{
- "url": "https://phpunit.de/sponsors.html",
- "type": "custom"
- },
+ "url": "https://phpunit.de/sponsoring.html",
+ "type": "other"
+ }
+ ],
+ "time": "2026-07-06T14:48:07+00:00"
+ },
+ {
+ "name": "rector/rector",
+ "version": "2.5.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/rectorphp/rector.git",
+ "reference": "861c77fd2a6d45317a401f4e0b617f947e8b6f96"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/rectorphp/rector/zipball/861c77fd2a6d45317a401f4e0b617f947e8b6f96",
+ "reference": "861c77fd2a6d45317a401f4e0b617f947e8b6f96",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4|^8.0",
+ "phpstan/phpstan": "^2.2.6"
+ },
+ "conflict": {
+ "rector/rector-doctrine": "*",
+ "rector/rector-downgrade-php": "*",
+ "rector/rector-phpunit": "*",
+ "rector/rector-symfony": "*"
+ },
+ "suggest": {
+ "ext-dom": "To manipulate phpunit.xml via the custom-rule command"
+ },
+ "bin": [
+ "bin/rector"
+ ],
+ "type": "library",
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Instant Upgrade and Automated Refactoring of any PHP code",
+ "homepage": "https://getrector.com/",
+ "keywords": [
+ "automation",
+ "dev",
+ "migration",
+ "refactoring"
+ ],
+ "support": {
+ "issues": "https://github.com/rectorphp/rector/issues",
+ "source": "https://github.com/rectorphp/rector/tree/2.5.8"
+ },
+ "funding": [
{
- "url": "https://github.com/sebastianbergmann",
+ "url": "https://github.com/tomasvotruba",
"type": "github"
- },
- {
- "url": "https://liberapay.com/sebastianbergmann",
- "type": "liberapay"
- },
- {
- "url": "https://thanks.dev/u/gh/sebastianbergmann",
- "type": "thanks_dev"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
- "type": "tidelift"
}
],
- "time": "2026-01-27T05:25:09+00:00"
+ "time": "2026-07-27T06:19:16+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -4594,28 +3731,27 @@
},
{
"name": "sirbrillig/phpcs-variable-analysis",
- "version": "v2.12.0",
+ "version": "v2.13.0",
"source": {
"type": "git",
"url": "https://github.com/sirbrillig/phpcs-variable-analysis.git",
- "reference": "4debf5383d9ade705e0a25121f16c3fecaf433a7"
+ "reference": "a15e970b8a0bf64cfa5e86d941f5e6b08855f369"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sirbrillig/phpcs-variable-analysis/zipball/4debf5383d9ade705e0a25121f16c3fecaf433a7",
- "reference": "4debf5383d9ade705e0a25121f16c3fecaf433a7",
+ "url": "https://api.github.com/repos/sirbrillig/phpcs-variable-analysis/zipball/a15e970b8a0bf64cfa5e86d941f5e6b08855f369",
+ "reference": "a15e970b8a0bf64cfa5e86d941f5e6b08855f369",
"shasum": ""
},
"require": {
"php": ">=5.4.0",
- "squizlabs/php_codesniffer": "^3.5.6"
+ "squizlabs/php_codesniffer": "^3.5.7 || ^4.0.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^0.7 || ^1.0",
- "phpcsstandards/phpcsdevcs": "^1.1",
- "phpstan/phpstan": "^1.7",
+ "phpstan/phpstan": "^1.7 || ^2.0",
"phpunit/phpunit": "^4.8.36 || ^5.7.21 || ^6.5 || ^7.0 || ^8.0 || ^9.0 || ^10.5.32 || ^11.3.3",
- "vimeo/psalm": "^0.2 || ^0.3 || ^1.1 || ^4.24 || ^5.0"
+ "vimeo/psalm": "^0.2 || ^0.3 || ^1.1 || ^4.24 || ^5.0 || ^6.0 || ^7.0"
},
"type": "phpcodesniffer-standard",
"autoload": {
@@ -4647,36 +3783,36 @@
"source": "https://github.com/sirbrillig/phpcs-variable-analysis",
"wiki": "https://github.com/sirbrillig/phpcs-variable-analysis/wiki"
},
- "time": "2025-03-17T16:17:38+00:00"
+ "time": "2025-09-30T22:22:48+00:00"
},
{
"name": "slevomat/coding-standard",
- "version": "8.16.0",
+ "version": "8.22.1",
"source": {
"type": "git",
"url": "https://github.com/slevomat/coding-standard.git",
- "reference": "7748a4282df19daf966fda1d8c60a8aec803c83a"
+ "reference": "1dd80bf3b93692bedb21a6623c496887fad05fec"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/7748a4282df19daf966fda1d8c60a8aec803c83a",
- "reference": "7748a4282df19daf966fda1d8c60a8aec803c83a",
+ "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/1dd80bf3b93692bedb21a6623c496887fad05fec",
+ "reference": "1dd80bf3b93692bedb21a6623c496887fad05fec",
"shasum": ""
},
"require": {
- "dealerdirect/phpcodesniffer-composer-installer": "^0.6.2 || ^0.7 || ^1.0",
+ "dealerdirect/phpcodesniffer-composer-installer": "^0.6.2 || ^0.7 || ^1.1.2",
"php": "^7.4 || ^8.0",
- "phpstan/phpdoc-parser": "^2.1.0",
- "squizlabs/php_codesniffer": "^3.11.3"
+ "phpstan/phpdoc-parser": "^2.3.0",
+ "squizlabs/php_codesniffer": "^3.13.4"
},
"require-dev": {
- "phing/phing": "3.0.1",
+ "phing/phing": "3.0.1|3.1.0",
"php-parallel-lint/php-parallel-lint": "1.4.0",
- "phpstan/phpstan": "2.1.6",
- "phpstan/phpstan-deprecation-rules": "2.0.1",
- "phpstan/phpstan-phpunit": "2.0.4",
- "phpstan/phpstan-strict-rules": "2.0.3",
- "phpunit/phpunit": "9.6.8|10.5.45|11.4.4|11.5.9|12.0.4"
+ "phpstan/phpstan": "2.1.24",
+ "phpstan/phpstan-deprecation-rules": "2.0.3",
+ "phpstan/phpstan-phpunit": "2.0.7",
+ "phpstan/phpstan-strict-rules": "2.0.6",
+ "phpunit/phpunit": "9.6.8|10.5.48|11.4.4|11.5.36|12.3.10"
},
"type": "phpcodesniffer-standard",
"extra": {
@@ -4700,7 +3836,7 @@
],
"support": {
"issues": "https://github.com/slevomat/coding-standard/issues",
- "source": "https://github.com/slevomat/coding-standard/tree/8.16.0"
+ "source": "https://github.com/slevomat/coding-standard/tree/8.22.1"
},
"funding": [
{
@@ -4712,20 +3848,20 @@
"type": "tidelift"
}
],
- "time": "2025-02-23T18:12:49+00:00"
+ "time": "2025-09-13T08:53:30+00:00"
},
{
"name": "squizlabs/php_codesniffer",
- "version": "3.12.0",
+ "version": "3.13.5",
"source": {
"type": "git",
"url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
- "reference": "2d1b63db139c3c6ea0c927698e5160f8b3b8d630"
+ "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/2d1b63db139c3c6ea0c927698e5160f8b3b8d630",
- "reference": "2d1b63db139c3c6ea0c927698e5160f8b3b8d630",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4",
+ "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4",
"shasum": ""
},
"require": {
@@ -4742,11 +3878,6 @@
"bin/phpcs"
],
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "3.x-dev"
- }
- },
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
@@ -4796,20 +3927,20 @@
"type": "thanks_dev"
}
],
- "time": "2025-03-18T05:04:51+00:00"
+ "time": "2025-11-04T16:30:35+00:00"
},
{
"name": "szepeviktor/phpstan-wordpress",
- "version": "v2.0.1",
+ "version": "v2.0.3",
"source": {
"type": "git",
"url": "https://github.com/szepeviktor/phpstan-wordpress.git",
- "reference": "f7beb13cd22998e3d913fdb897a1e2553ccd637e"
+ "reference": "aa722f037b2d034828cd6c55ebe9e5c74961927e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/szepeviktor/phpstan-wordpress/zipball/f7beb13cd22998e3d913fdb897a1e2553ccd637e",
- "reference": "f7beb13cd22998e3d913fdb897a1e2553ccd637e",
+ "url": "https://api.github.com/repos/szepeviktor/phpstan-wordpress/zipball/aa722f037b2d034828cd6c55ebe9e5c74961927e",
+ "reference": "aa722f037b2d034828cd6c55ebe9e5c74961927e",
"shasum": ""
},
"require": {
@@ -4819,6 +3950,7 @@
},
"require-dev": {
"composer/composer": "^2.1.14",
+ "composer/semver": "^3.4",
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"php-parallel-lint/php-parallel-lint": "^1.1",
"phpstan/phpstan-strict-rules": "^2.0",
@@ -4856,9 +3988,9 @@
],
"support": {
"issues": "https://github.com/szepeviktor/phpstan-wordpress/issues",
- "source": "https://github.com/szepeviktor/phpstan-wordpress/tree/v2.0.1"
+ "source": "https://github.com/szepeviktor/phpstan-wordpress/tree/v2.0.3"
},
- "time": "2024-12-01T02:13:05+00:00"
+ "time": "2025-09-14T02:58:22+00:00"
},
{
"name": "theseer/tokenizer",
@@ -4910,18 +4042,75 @@
],
"time": "2025-11-17T20:03:58+00:00"
},
+ {
+ "name": "tomasvotruba/type-coverage",
+ "version": "2.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/TomasVotruba/type-coverage.git",
+ "reference": "4087caa4639bdd4c646f2984bc333efeddf69e4b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/TomasVotruba/type-coverage/zipball/4087caa4639bdd4c646f2984bc333efeddf69e4b",
+ "reference": "4087caa4639bdd4c646f2984bc333efeddf69e4b",
+ "shasum": ""
+ },
+ "require": {
+ "nette/utils": "^3.2 || ^4.0",
+ "php": "^7.4 || ^8.0",
+ "phpstan/phpstan": "^2.1.33"
+ },
+ "type": "phpstan-extension",
+ "extra": {
+ "phpstan": {
+ "includes": [
+ "config/extension.neon"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "TomasVotruba\\TypeCoverage\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Measure type coverage of your project",
+ "keywords": [
+ "phpstan-extension",
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/TomasVotruba/type-coverage/issues",
+ "source": "https://github.com/TomasVotruba/type-coverage/tree/2.2.1"
+ },
+ "funding": [
+ {
+ "url": "https://www.paypal.me/rectorphp",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/tomasvotruba",
+ "type": "github"
+ }
+ ],
+ "time": "2026-05-26T08:14:01+00:00"
+ },
{
"name": "wp-coding-standards/wpcs",
- "version": "3.1.0",
+ "version": "3.4.1",
"source": {
"type": "git",
"url": "https://github.com/WordPress/WordPress-Coding-Standards.git",
- "reference": "9333efcbff231f10dfd9c56bb7b65818b4733ca7"
+ "reference": "ec2ff942335f33683a5957a85d138753876a05cf"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/9333efcbff231f10dfd9c56bb7b65818b4733ca7",
- "reference": "9333efcbff231f10dfd9c56bb7b65818b4733ca7",
+ "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/ec2ff942335f33683a5957a85d138753876a05cf",
+ "reference": "ec2ff942335f33683a5957a85d138753876a05cf",
"shasum": ""
},
"require": {
@@ -4929,17 +4118,17 @@
"ext-libxml": "*",
"ext-tokenizer": "*",
"ext-xmlreader": "*",
- "php": ">=5.4",
- "phpcsstandards/phpcsextra": "^1.2.1",
- "phpcsstandards/phpcsutils": "^1.0.10",
- "squizlabs/php_codesniffer": "^3.9.0"
+ "php": ">=7.2",
+ "phpcsstandards/phpcsextra": "^1.5.1",
+ "phpcsstandards/phpcsutils": "^1.2.3",
+ "squizlabs/php_codesniffer": "^3.13.5"
},
"require-dev": {
"php-parallel-lint/php-console-highlighter": "^1.0.0",
- "php-parallel-lint/php-parallel-lint": "^1.3.2",
- "phpcompatibility/php-compatibility": "^9.0",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "phpcompatibility/php-compatibility": "^10.0.0@dev",
"phpcsstandards/phpcsdevtools": "^1.2.0",
- "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0"
+ "phpunit/phpunit": "^8.0 || ^9.0"
},
"suggest": {
"ext-iconv": "For improved results",
@@ -4974,20 +4163,20 @@
"type": "custom"
}
],
- "time": "2024-03-25T16:39:00+00:00"
+ "time": "2026-07-27T11:53:23+00:00"
},
{
"name": "yoast/phpunit-polyfills",
- "version": "2.0.4",
+ "version": "2.0.5",
"source": {
"type": "git",
"url": "https://github.com/Yoast/PHPUnit-Polyfills.git",
- "reference": "a0e3e9adecaa352697786cb29bb0f2fcc25f43f5"
+ "reference": "1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/a0e3e9adecaa352697786cb29bb0f2fcc25f43f5",
- "reference": "a0e3e9adecaa352697786cb29bb0f2fcc25f43f5",
+ "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27",
+ "reference": "1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27",
"shasum": ""
},
"require": {
@@ -4997,7 +4186,7 @@
"require-dev": {
"php-parallel-lint/php-console-highlighter": "^1.0.0",
"php-parallel-lint/php-parallel-lint": "^1.4.0",
- "yoast/yoastcs": "^3.1.0"
+ "yoast/yoastcs": "^3.2.0"
},
"type": "library",
"extra": {
@@ -5037,26 +4226,17 @@
"security": "https://github.com/Yoast/PHPUnit-Polyfills/security/policy",
"source": "https://github.com/Yoast/PHPUnit-Polyfills"
},
- "time": "2025-02-09T18:25:29+00:00"
- }
- ],
- "aliases": [
- {
- "package": "phpcompatibility/php-compatibility",
- "version": "dev-develop",
- "alias": "9.99.99",
- "alias_normalized": "9.99.99.0"
+ "time": "2025-08-10T05:13:49+00:00"
}
],
+ "aliases": [],
"minimum-stability": "stable",
- "stability-flags": {
- "phpcompatibility/php-compatibility": 20
- },
+ "stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
- "php": ">=8.2"
+ "php": ">=8.3"
},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/docs/Asset-Loading.md b/docs/Asset-Loading.md
index a37b67b..f8606dc 100644
--- a/docs/Asset-Loading.md
+++ b/docs/Asset-Loading.md
@@ -1,7 +1,16 @@
# Asset Loading
## Overview
-Use the `TenupFramework\Assets\GetAssetInfo` trait to read dependency and version metadata generated by your build (the `.asset.php` sidecar files). The trait looks for files in:
+Use the `TenupFramework\Assets\GetAssetInfo` trait to read dependency and version metadata generated by your build (the `.asset.php` sidecar files). The trait supports two approaches for locating asset files:
+
+### Prefix-based paths (recommended)
+When you specify a prefix in the slug, the trait looks for files directly in the corresponding subdirectory:
+- `get_asset_info('css/admin')` → `dist/css/admin.asset.php`
+- `get_asset_info('js/admin')` → `dist/js/admin.asset.php`
+- `get_asset_info('blocks/my-block')` → `dist/blocks/my-block.asset.php`
+
+### Fallback behavior
+For slugs without prefixes, the trait searches in this order:
- `dist/js/{slug}.asset.php`
- `dist/css/{slug}.asset.php`
- `dist/blocks/{slug}.asset.php`
@@ -55,6 +64,19 @@ Notes:
- If your build produces multiple variants (e.g., `admin.js` vs `admin.min.js`), you can conditionally enqueue based on `SCRIPT_DEBUG` or `wp_get_environment_type() === 'development'`.
## Enqueuing scripts
+
+### Using prefix-based paths (recommended)
+```php
+wp_enqueue_script(
+ 'tenup_plugin_admin',
+ YOUR_PLUGIN_URL . 'dist/js/admin.js',
+ $this->get_asset_info( 'js/admin', 'dependencies' ),
+ $this->get_asset_info( 'js/admin', 'version' ),
+ true
+);
+```
+
+### Using fallback behavior
```php
wp_enqueue_script(
'tenup_plugin_admin',
@@ -68,6 +90,18 @@ wp_enqueue_script(
- version: string used for cache busting
## Enqueuing styles
+
+### Using prefix-based paths (recommended)
+```php
+wp_enqueue_style(
+ 'tenup_plugin_admin',
+ YOUR_PLUGIN_URL . 'dist/css/admin.css',
+ [], // CSS dependencies are uncommon; pass [] unless needed
+ $this->get_asset_info( 'css/admin', 'version' )
+);
+```
+
+### Using fallback behavior
```php
wp_enqueue_style(
'tenup_plugin_admin',
@@ -78,7 +112,17 @@ wp_enqueue_style(
```
## Working with blocks
-If you build blocks, pass the block slug used by your build tool:
+
+### Using prefix-based paths (recommended)
+```php
+$deps = $this->get_asset_info( 'blocks/my-block', 'dependencies' );
+$ver = $this->get_asset_info( 'blocks/my-block', 'version' );
+$handle = 'tenup_my_block';
+
+wp_register_script( $handle, YOUR_PLUGIN_URL . 'dist/blocks/my-block.js', $deps, $ver, true );
+```
+
+### Using fallback behavior
```php
$deps = $this->get_asset_info( 'my-block', 'dependencies' );
$ver = $this->get_asset_info( 'my-block', 'version' );
@@ -88,6 +132,21 @@ wp_register_script( $handle, YOUR_PLUGIN_URL . 'dist/blocks/my-block.js', $deps,
```
The trait automatically checks `dist/blocks/my-block.asset.php` if present.
+## Resolving asset conflicts
+
+When you have JS and CSS assets with the same handle (e.g., both `admin.js` and `admin.css`), use prefix-based paths to avoid conflicts:
+
+```php
+// ❌ Problematic: Both would load the same asset data
+$this->get_asset_info( 'admin', 'dependencies' ); // Could load JS instead of CSS data if both CSS and JS files exist with the same name.
+
+// ✅ Recommended: Explicitly specify the asset type
+$this->get_asset_info( 'js/admin', 'dependencies' ); // Always loads JS asset data
+$this->get_asset_info( 'css/admin', 'dependencies' ); // Always loads CSS asset data
+```
+
+This ensures that each asset type gets its correct dependencies and version information.
+
## Error handling and fallbacks
```php
try {
@@ -105,6 +164,8 @@ try {
- Keep your dist path stable across environments (use constants for PATH and URL).
- Use the version from `.asset.php` for reliable cache busting in production.
- For admin-only assets, enqueue on `admin_enqueue_scripts`; for frontend, use `wp_enqueue_scripts`.
+- **Use prefix-based paths** (`'js/admin'`, `'css/admin'`, `'blocks/my-block'`) to avoid conflicts when you have assets with the same handle across different types.
+- Prefix-based paths are **backwards compatible** - existing code using fallback behavior will continue to work.
## See also
- [Docs Home](README.md)
diff --git a/docs/Autoloading.md b/docs/Autoloading.md
index 0b7038d..40814a5 100644
--- a/docs/Autoloading.md
+++ b/docs/Autoloading.md
@@ -78,11 +78,17 @@ add_action( 'plugins_loaded', function () {
} );
```
-Environment caching:
-- Discovery results are cached only in production and staging environments (per `wp_get_environment_type()`).
-- Cache is stored under the directory you pass to `init_classes()`, in a "class-loader-cache" folder (e.g., `YOUR_PLUGIN_INC . 'class-loader-cache'`).
-- To refresh: delete that folder; it will be rebuilt automatically.
-- Caching is skipped entirely when the constant `VIP_GO_APP_ENVIRONMENT` is defined or when `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` is set to `true`. Use `define( 'TENUP_FRAMEWORK_DISABLE_CLASS_CACHE', true )` in environments that don't support writable file systems.
+Class caching (optional, build-time):
+- Discovery is fast, but on large codebases you can cache the discovered class list. The cache is **opt-in and produced at build time**: the framework reads it at runtime but never writes it, so it can never go stale on a server.
+- With no cache file present (the default), classes are discovered live on every request. This is correct and is the right default for small projects.
+- To produce a cache, run the shipped command in your build/deploy pipeline and ship the result as a build artefact:
+ ```bash
+ vendor/bin/tenup-framework-generate-class-cache YOUR_PLUGIN_INC
+ # or, via the Composer alias (see Build and Deployment):
+ composer generate-class-cache -- inc/
+ ```
+- Define `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` as `true` to ignore any shipped cache and always discover live (useful for debugging a suspected stale cache).
+- See [Build and Deployment](Build-and-Deployment.md) for CI examples and the per-package caching model.
## Defining a Module
```php
@@ -116,6 +122,7 @@ class YourModule implements ModuleInterface {
## See also
- [Docs Home](README.md)
- [Modules and Initialization](Modules-and-Initialization.md)
+- [Build and Deployment](Build-and-Deployment.md)
- [Post Types](Post-Types.md)
- [Taxonomies](Taxonomies.md)
- [Asset Loading](Asset-Loading.md)
diff --git a/docs/BlockRegistrar-Usage.md b/docs/BlockRegistrar-Usage.md
new file mode 100644
index 0000000..c181300
--- /dev/null
+++ b/docs/BlockRegistrar-Usage.md
@@ -0,0 +1,410 @@
+# BlockRegistrar Usage Guide
+
+The `BlockRegistrar` class provides automatic block registration from `block.json` files. This guide shows how to use it in themes and plugins.
+
+## Table of Contents
+
+- [Basic Usage](#basic-usage)
+- [Theme Implementation](#theme-implementation)
+- [Plugin Implementation](#plugin-implementation)
+- [Multiple Directories](#multiple-directories)
+- [Block Structure](#block-structure)
+- [Advanced Features](#advanced-features)
+- [Troubleshooting](#troubleshooting)
+
+## Basic Usage
+
+### 1. Extend BlockRegistrar
+
+Create a class that extends `BlockRegistrar` and implement the required method:
+
+```php
+ Array of paths to the blocks directories.
+ */
+ public function get_blocks_directory(): array {
+ return [ YOUR_BLOCKS_DIRECTORY ];
+ }
+}
+```
+
+### 2. Automatic Registration
+
+The framework automatically discovers and registers your `Blocks` class through `ModuleInitialization`. No manual registration needed!
+
+## Theme Implementation
+
+### Directory Structure
+
+```
+your-theme/
+├── src/
+│ └── Blocks.php
+├── blocks/
+│ ├── example-block/
+│ │ ├── block.json
+│ │ ├── edit.js
+│ │ ├── index.js
+│ │ ├── markup.php
+│ │ └── save.js
+│ └── hero-block/
+│ ├── block.json
+│ ├── edit.js
+│ ├── index.js
+│ ├── markup.php
+│ └── save.js
+└── functions.php
+```
+
+### Theme Blocks Class
+
+```php
+ Array of paths to the blocks directories.
+ */
+ public function get_blocks_directory(): array {
+ return [
+ get_template_directory() . '/blocks/',
+ ];
+ }
+}
+```
+
+### Block Definition (block.json)
+
+```json
+{
+ "name": "your-theme/hero",
+ "title": "Hero Block",
+ "description": "A hero section block",
+ "category": "layout",
+ "icon": "cover-image",
+ "keywords": ["hero", "banner", "header"],
+ "supports": {
+ "align": ["wide", "full"],
+ "color": {
+ "background": true,
+ "text": true
+ }
+ },
+ "attributes": {
+ "title": {
+ "type": "string",
+ "default": "Welcome"
+ },
+ "subtitle": {
+ "type": "string",
+ "default": "Subtitle text"
+ }
+ },
+ "editorScript": "file:./build/index.js",
+ "editorStyle": "file:./build/editor.css",
+ "style": "file:./build/style.css"
+}
+```
+
+## Plugin Implementation
+
+### Directory Structure
+
+```
+your-plugin/
+├── src/
+│ └── Blocks.php
+├── blocks/
+│ ├── contact-form/
+│ │ ├── block.json
+│ │ ├── edit.js
+│ │ ├── index.js
+│ │ ├── markup.php
+│ │ └── save.js
+│ └── testimonials/
+│ ├── block.json
+│ ├── edit.js
+│ ├── index.js
+│ ├── markup.php
+│ └── save.js
+└── plugin.php
+```
+
+### Plugin Blocks Class
+
+```php
+ Array of paths to the blocks directories.
+ */
+ public function get_blocks_directory(): array {
+ return [
+ plugin_dir_path( __FILE__ ) . 'blocks/',
+ ];
+ }
+}
+```
+
+## Multiple Directories
+
+You can register blocks from multiple directories:
+
+```php
+public function get_blocks_directory(): array {
+ return [
+ get_template_directory() . '/blocks/', // Theme blocks
+ get_template_directory() . '/custom-blocks/', // Custom theme blocks
+ plugin_dir_path( __FILE__ ) . 'vendor-blocks/', // Third-party blocks
+ ];
+}
+```
+
+## Block Structure
+
+### Required Files
+
+Each block directory must contain:
+
+- **`block.json`** - Block metadata (required)
+- **`index.js`** - Block JavaScript (required)
+
+### Standard Files (Recommended)
+
+- **`edit.js`** - Editor component (recommended)
+- **`save.js`** - Save component (recommended)
+- **`markup.php`** - Server-side rendering (recommended for dynamic blocks)
+
+### Optional Files
+
+- **`style.css`** - Block styles
+- **`editor.css`** - Editor-only styles
+
+### Dynamic Blocks with Server-Side Rendering
+
+For blocks that need server-side rendering, add a `markup.php` file:
+
+```php
+
+
+
+```
+
+The `BlockRegistrar` automatically detects `markup.php` files and creates render callbacks.
+
+## Advanced Features
+
+### Conflict Detection
+
+The `BlockRegistrar` automatically detects block name conflicts between themes and plugins:
+
+```php
+// Check if a block has conflicts
+if ( \TenupFramework\BlockRegistrar::has_block_conflict( 'theme/hero' ) ) {
+ $source = \TenupFramework\BlockRegistrar::get_block_source( 'theme/hero' );
+ error_log( "Block 'theme/hero' already registered by: {$source}" );
+}
+
+// Get all registered blocks and their sources
+$sources = \TenupFramework\BlockRegistrar::get_all_block_sources();
+foreach ( $sources as $block_name => $source_class ) {
+ echo "Block '{$block_name}' registered by: {$source_class}\n";
+}
+```
+
+### Error Handling
+
+The `BlockRegistrar` provides comprehensive error handling:
+
+- **Invalid directories** - Skipped with error logging
+- **Malformed JSON** - Skipped with error logging
+- **Missing required fields** - Skipped with error logging
+- **Block registration failures** - Logged with details
+
+### Security Features
+
+- **Path validation** - Prevents directory traversal attacks
+- **JSON validation** - Ensures proper block metadata
+- **File permission checks** - Verifies readable directories
+- **Block name validation** - Enforces proper naming conventions
+
+## Troubleshooting
+
+### Common Issues
+
+#### 1. Blocks Not Appearing
+
+**Problem**: Blocks don't appear in the editor.
+
+**Solutions**:
+- Check that `block.json` exists and is valid JSON
+- Verify the block name follows `namespace/name` format
+- Ensure the directory path is correct
+- Check WordPress error logs for registration errors
+
+#### 2. Block Name Conflicts
+
+**Problem**: "Block name conflict detected" error.
+
+**Solutions**:
+- Use unique block names (e.g., `theme/hero`, `plugin/form`)
+- Check which class registered the conflicting block
+- Consider using different namespaces
+
+#### 3. Server-Side Rendering Not Working
+
+**Problem**: Dynamic blocks don't render on the frontend.
+
+**Solutions**:
+- Ensure `markup.php` exists in the block directory
+- Check that `markup.php` has proper PHP syntax
+- Verify the block is registered as dynamic in `block.json`
+
+#### 4. Styles Not Loading
+
+**Problem**: Block styles don't appear.
+
+**Solutions**:
+- Check `style.css` path in `block.json`
+- Ensure the CSS file exists
+- Verify the `style` property is correctly set
+
+### Debug Information
+
+Enable WordPress debug logging to see detailed error messages:
+
+```php
+// In wp-config.php
+define( 'WP_DEBUG', true );
+define( 'WP_DEBUG_LOG', true );
+```
+
+Check `/wp-content/debug.log` for `BlockRegistrar` error messages.
+
+### Testing Your Implementation
+
+```php
+// Test that your blocks class is working
+$blocks = new YourTheme\Blocks();
+$directories = $blocks->get_blocks_directory();
+
+// Check if directories exist
+foreach ( $directories as $dir ) {
+ if ( ! file_exists( $dir ) ) {
+ error_log( "Block directory does not exist: {$dir}" );
+ }
+}
+```
+
+## Best Practices
+
+1. **Use descriptive block names** - `theme/hero` instead of `theme/block1`
+2. **Organize blocks logically** - Group related blocks in subdirectories
+3. **Include proper metadata** - Complete `block.json` with all required fields
+4. **Test thoroughly** - Verify blocks work in both editor and frontend
+5. **Handle errors gracefully** - Check error logs regularly
+6. **Use consistent naming** - Follow your project's naming conventions
+
+## Examples
+
+### Complete Theme Example
+
+```php
+ [ ...]
+```
+
+Pass the same directory you pass to `ModuleInitialization::init_classes()` — usually your
+plugin/theme `inc/`. It writes `class-loader-cache/class-loader-cache-v2.php` inside each
+directory. Multiple directories may be passed in one call.
+
+### Composer alias
+
+For nicer ergonomics, add a one-line script alias to your project's `composer.json`:
+
+```json
+{
+ "scripts": {
+ "generate-class-cache": "tenup-framework-generate-class-cache inc/"
+ }
+}
+```
+
+Then run it with:
+
+```bash
+composer generate-class-cache
+```
+
+> Composer does not propagate a dependency's scripts into your project, so the alias lives in
+> your own `composer.json`. The `vendor/bin` command is the portable entry point either way.
+
+### Bypassing the cache
+
+Define `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` as `true` (e.g. in `wp-config.php`) to ignore any
+shipped cache and always discover live. Useful when debugging a suspected stale or incorrect
+cache.
+
+## Gitignore the cache
+
+The cache is a build artefact, not source. Ignore it and regenerate it on every deploy so an
+old copy can never linger:
+
+```gitignore
+# Generated class-loader cache (built in CI, shipped with the deploy)
+**/class-loader-cache/
+```
+
+## Wiring it into CI
+
+The generate step always sits **after** `composer install` (it needs the framework and its
+dependencies on disk) and **before** the deploy/packaging step (so the cache ships with the
+build). `spatie/php-structure-discoverer` and this command are runtime dependencies, so
+`composer install --no-dev` keeps them available.
+
+### GitHub Actions
+
+```yaml
+name: Deploy
+on:
+ push:
+ branches: [ main ]
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+ tools: composer:v2
+ - name: Install PHP dependencies
+ run: composer install --no-dev --prefer-dist --no-progress
+ - name: Generate the class-loader cache
+ run: composer generate-class-cache
+ # or: vendor/bin/tenup-framework-generate-class-cache inc/
+ - name: Deploy
+ run: ./bin/deploy.sh # your deploy ships inc/class-loader-cache/ with the build
+```
+
+### GitLab CI
+
+```yaml
+stages:
+ - build
+ - deploy
+
+build:
+ stage: build
+ image: php:8.3
+ script:
+ - composer install --no-dev --prefer-dist --no-progress
+ - vendor/bin/tenup-framework-generate-class-cache inc/
+ artifacts:
+ paths:
+ - vendor/
+ - inc/class-loader-cache/
+
+deploy:
+ stage: deploy
+ script:
+ - ./bin/deploy.sh # ships the artifacts produced by the build stage
+```
+
+### CircleCI
+
+```yaml
+version: 2.1
+
+jobs:
+ build-and-deploy:
+ docker:
+ - image: cimg/php:8.3
+ steps:
+ - checkout
+ - run:
+ name: Install PHP dependencies
+ command: composer install --no-dev --prefer-dist --no-progress
+ - run:
+ name: Generate the class-loader cache
+ command: vendor/bin/tenup-framework-generate-class-cache inc/
+ - run:
+ name: Deploy
+ command: ./bin/deploy.sh # ships inc/class-loader-cache/ with the build
+
+workflows:
+ deploy:
+ jobs:
+ - build-and-deploy:
+ filters:
+ branches:
+ only: main
+```
+
+If the build can't run the generate step for some reason, the deploy still works — it just
+runs uncached. A broken cache after a build means the build is the thing to fix, not the
+server. A cache file that is corrupt or truncated (a half-finished rsync, an interrupted
+build) is caught at runtime and the request falls back to a live scan, so a bad cache slows
+the site rather than taking it down.
+
+### Opcache and in-place deploys
+
+The cache is a PHP file loaded with `require`, so PHP's opcache caches it like any other
+source file. On hosts with `opcache.validate_timestamps=0` (common on the managed hosts where
+issue #30 was reported), overwriting `class-loader-cache-v2.php` **in place** keeps serving the
+previously compiled array until opcache is reset — which would reintroduce the very staleness
+this design removes. Either deploy to a fresh path (atomic symlink swap, the default on most
+zero-downtime deployers) or reset opcache as part of the deploy. The loader debug page's
+staleness check live-scans and will flag this if it happens.
+
+## The per-package model
+
+The framework discovers classes from **one directory mapped to one namespace, per Composer
+package**. Each plugin or theme that uses the framework is its own unit: its own namespace,
+its own `inc/` (or `src/`) directory, its own cache, and it requires the framework via
+Composer in its own `composer.json`.
+
+This is why caching is per package rather than per project. A single cache at the project
+root could not tell which discovered classes belong to which plugin without scanning
+everything — which is exactly the work the cache exists to avoid. So the cache is generated
+per package, into the directory passed to that package's `init_classes()`, and shipped inside
+that package.
+
+The trade-off is that each package carries its own `vendor/` (a duplicated framework install)
+and its own CI generate step, in exchange for domain-focused units that decouple cleanly. If
+your build produces several packages, generate each one's cache — either with a call per
+package, or by passing every directory to a single invocation:
+
+```bash
+vendor/bin/tenup-framework-generate-class-cache \
+ wp-content/plugins/foo/inc \
+ wp-content/plugins/bar/inc
+```
+
+One caveat for a mono-repo where packages pin **different** framework versions: the single
+invocation above uses one package's `vendor/bin` copy to write every directory's cache. That
+copy determines the cache filename and the Spatie discoverer version used. Today the payload is
+a plain array of class-name strings and the filename is identical across versions, so this is
+safe — but a future cache-format or filename bump would silently mismatch. When packages are on
+different framework versions, run **each package's own** `vendor/bin/tenup-framework-generate-class-cache`
+against its own directory so the writer and the reader are always the same version.
+
+## See also
+- [Docs Home](README.md)
+- [Autoloading and Modules](Autoloading.md)
+- [Modules and Initialization](Modules-and-Initialization.md)
diff --git a/docs/Core-Modules.md b/docs/Core-Modules.md
new file mode 100644
index 0000000..cac4359
--- /dev/null
+++ b/docs/Core-Modules.md
@@ -0,0 +1,180 @@
+# Core Modules
+
+WP Framework provides several Core modules that handle common WordPress functionality modifications. These modules are designed to be explicitly opted into by consuming applications (themes/plugins) rather than being automatically initialized.
+
+## Available Core Modules
+
+### HeadOverrides
+
+The `HeadOverrides` module removes unwanted WordPress head elements that are typically not needed in production sites.
+
+**Location**: `TenupFramework\Core\HeadOverrides`
+
+**Functionality**:
+- Removes WordPress generator meta tag (`wp_generator`)
+- Removes Windows Live Writer manifest link (`wlwmanifest_link`)
+- Removes Really Simple Discovery service endpoint link (`rsd_link`)
+
+**Usage**:
+```php
+// Recommended: Array-based approach
+$core_modules = [ \TenupFramework\Core\HeadOverrides::class ];
+foreach ( $core_modules as $module_class ) {
+ $module = new $module_class();
+ if ( $module->can_register() ) {
+ $module->register();
+ }
+}
+```
+
+### Emoji
+
+The `Emoji` module disables WordPress core emoji functionality, which can improve performance by removing unnecessary scripts and styles.
+
+**Location**: `TenupFramework\Core\Emoji`
+
+**Functionality**:
+- Removes emoji detection scripts from `wp_head` and admin
+- Removes emoji-related styles from front-end and back-end
+- Removes emoji-to-static-img conversion from feeds and email
+- Disables TinyMCE emoji plugin
+- Removes emoji CDN from DNS prefetching hints
+
+**Usage**:
+```php
+// Recommended: Array-based approach
+$core_modules = [ \TenupFramework\Core\Emoji::class ];
+foreach ( $core_modules as $module_class ) {
+ $module = new $module_class();
+ if ( $module->can_register() ) {
+ $module->register();
+ }
+}
+```
+
+## Core Module Characteristics
+
+All Core modules follow these patterns:
+
+### Module Interface Compliance
+- Implement `TenupFramework\ModuleInterface`
+- Use the `TenupFramework\Module` trait
+- Provide `load_order()`, `can_register()`, and `register()` methods
+
+### Load Order
+Core modules typically use a load order of `5`, ensuring they initialize early in the module lifecycle.
+
+### Explicit Opt-In
+Core modules are **not automatically initialized** by the framework. Consuming applications must explicitly instantiate and register them.
+
+## Integration Patterns
+
+### Recommended: Array-Based Approach
+The cleanest way to initialize Core modules is using an array and foreach loop:
+
+```php
+// Define the Core modules you want to use
+$core_modules = [
+ \TenupFramework\Core\HeadOverrides::class,
+ \TenupFramework\Core\Emoji::class,
+];
+
+// Initialize each module
+foreach ( $core_modules as $module_class ) {
+ $module = new $module_class();
+ if ( $module->can_register() ) {
+ $module->register();
+ }
+}
+```
+
+### Manual Instantiation (Alternative)
+```php
+// Initialize specific Core modules individually
+$head_overrides = new \TenupFramework\Core\HeadOverrides();
+if ( $head_overrides->can_register() ) {
+ $head_overrides->register();
+}
+
+$emoji = new \TenupFramework\Core\Emoji();
+if ( $emoji->can_register() ) {
+ $emoji->register();
+}
+```
+
+### Configuration-Based Approach (Future Enhancement)
+If using the configuration-based initialization pattern:
+
+```php
+// Using ModuleInitialization::init_specific_modules()
+ModuleInitialization::instance()->init_specific_modules([
+ \TenupFramework\Core\HeadOverrides::class,
+ \TenupFramework\Core\Emoji::class,
+]);
+```
+
+## Best Practices
+
+### When to Use Core Modules
+- **HeadOverrides**: Use when you want to remove WordPress generator meta and other unnecessary head elements
+- **Emoji**: Use when you want to disable WordPress emoji functionality for performance reasons
+
+### Conditional Loading
+Consider loading Core modules conditionally based on your application's needs:
+
+```php
+// Build array of Core modules based on conditions
+$core_modules = [ \TenupFramework\Core\HeadOverrides::class ];
+
+// Only load emoji module in production
+if ( wp_get_environment_type() === 'production' ) {
+ $core_modules[] = \TenupFramework\Core\Emoji::class;
+}
+
+// Initialize all selected modules
+foreach ( $core_modules as $module_class ) {
+ $module = new $module_class();
+ if ( $module->can_register() ) {
+ $module->register();
+ }
+}
+```
+
+### Testing
+Core modules include comprehensive test suites that verify:
+- Interface implementation
+- Method existence and callability
+- Source code verification of WordPress function calls
+- Functional behavior testing
+
+## Migration from Scaffold
+
+If migrating from the WP Scaffold plugin's Core modules:
+
+1. **Remove** the old Core module classes from your scaffold
+2. **Add explicit opt-in** to the framework's Core modules
+3. **Test** that functionality works as expected
+4. **Verify** no regressions in your application
+
+## Future Enhancements
+
+### Framework Helper Method
+A future enhancement could add a helper method to the `ModuleInitialization` class:
+
+```php
+// Potential future API
+ModuleInitialization::init_core_modules([
+ \TenupFramework\Core\HeadOverrides::class,
+ \TenupFramework\Core\Emoji::class,
+]);
+```
+
+This would internally handle the instantiation and registration logic, making the array-based approach even cleaner.
+
+## Future Core Modules
+
+The framework is designed to accommodate additional Core modules as needed. New modules should follow the same patterns:
+- Implement `ModuleInterface`
+- Use the `Module` trait
+- Provide comprehensive test coverage
+- Require explicit opt-in from consuming applications
diff --git a/docs/Debugging.md b/docs/Debugging.md
new file mode 100644
index 0000000..18de671
--- /dev/null
+++ b/docs/Debugging.md
@@ -0,0 +1,103 @@
+# Debugging class loaders
+
+The framework ships a hidden admin page that shows the state of every class-loader cache active
+on a site. It exists to answer one question quickly: **what is each loader actually loading, and
+is any cache stale?** That matters most on production, where a failed build or a missed deploy can
+leave an old cache file in place (see [Build and Deployment](Build-and-Deployment.md) and
+[issue #30](https://github.com/10up/wp-framework/issues/30)).
+
+## Opening the page
+
+There is no menu item — the page is hidden. Visit it directly:
+
+```
+/wp-admin/admin.php?page=tenup-framework-loaders
+```
+
+It requires the `manage_options` capability.
+
+## What it shows
+
+A site can run **1..n** framework copies (one per plugin/theme that requires the package). The
+page aggregates every loader recorded across all of them — even copies that are php-scoped
+(prefixed) or on different versions — by collecting over the fixed-string
+`tenup_framework_debug_loaders` filter. For each loader you get:
+
+- **Owner** — the plugin or theme the directory belongs to (derived from the path).
+- **Directory** — the directory passed to `ModuleInitialization::init_classes()`.
+- **Framework version** — version and git reference of the copy that recorded it, so a
+ version mismatch between plugins is visible.
+- **Cache file** — its path, and the status: in use, present-but-not-used, discovering live
+ (no file), disabled, or **failed to load** (a present cache that was corrupt or truncated, so
+ the runtime fell back to a live scan — badged red). When a file is present, its age and size.
+- **Stale cache files** — a warning if the cache directory holds files other than the current
+ one (usually leftovers from an older framework version).
+- **Classes loaded** — every class the loader resolved, with the file each one lives in. A class
+ that no longer resolves is flagged as a likely stale entry.
+- **Discovery time** — how long this request spent obtaining the class list. With a cache present
+ this is the cost of reading it; uncached it is the cost of a live filesystem scan, so the two
+ states can be compared directly.
+- **Class lookup time** — how long reflecting, instantiating and registering the discovered
+ classes took.
+
+## Staleness check
+
+Each loader has a **Check this cache for staleness** button. It re-runs discovery live against the
+directory and diffs the result against what the cache loaded, listing:
+
+- classes **on disk but missing from the cache** (the cache is behind), and
+- classes **in the cache but no longer on disk** (renamed/removed).
+
+It also reports **how long the live discovery took**, which — compared against the cached
+**Discovery time** above — shows what the cache is actually saving on this site. The check runs
+only when clicked, so the page itself stays cheap. If it reports drift, the cache
+is stale: regenerate it in your build (`composer generate-class-cache`) or remove the file and
+redeploy. The page is **read-only** — it never deletes or rewrites a cache, consistent with the
+read-only runtime.
+
+## Performance
+
+The recording and the page are **admin-only**. On front-end requests nothing is recorded, no hooks
+are added, and the debug class is never even loaded.
+
+## Corrupt cache
+
+If a shipped cache file is corrupt or truncated (a partial deploy or half-written rsync), the
+runtime catches it and falls back to a live scan rather than fataling the request. The debug page
+flags that loader red as **"Cache failed to load — running live"** so the degraded state is
+visible rather than looking healthy. To alert or log on it outside the admin, hook the action that
+fires on every fallback:
+
+```php
+add_action( 'tenup_framework_cache_load_failed', function ( $dir, $error ) {
+ error_log( "WP Framework: class cache for {$dir} failed to load: " . $error->getMessage() );
+}, 10, 2 );
+```
+
+The fix is to rebuild the cache in your pipeline and redeploy.
+
+## Known limitations
+
+- **Per request** — the page shows loaders recorded on the current admin request. A plugin whose
+ `init_classes()` did not run on this request will not appear.
+- **Mixed framework versions render with the oldest UI** — when a mono-repo runs several framework
+ copies on different versions, the first copy to record a loader registers and renders the page,
+ so newer per-loader fields degrade to blank rather than showing. Aligning framework versions
+ across packages avoids this; the data itself is still aggregated correctly across all copies.
+
+## Disabling it
+
+Enabled by default in the admin. Turn it off with either:
+
+```php
+add_filter( 'tenup_framework_enable_loader_debug', '__return_false' );
+```
+
+```php
+define( 'TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG', true );
+```
+
+## See also
+- [Docs Home](README.md)
+- [Build and Deployment](Build-and-Deployment.md)
+- [Modules and Initialization](Modules-and-Initialization.md)
diff --git a/docs/Modules-and-Initialization.md b/docs/Modules-and-Initialization.md
index e00a34d..6869783 100644
--- a/docs/Modules-and-Initialization.md
+++ b/docs/Modules-and-Initialization.md
@@ -23,8 +23,7 @@ ModuleInitialization::instance()->init_classes( YOUR_PLUGIN_INC );
ModuleInitialization performs the following steps:
1. Validate the directory exists; otherwise throw a RuntimeException.
2. Discover class names within the directory using spatie/structure-discoverer.
- - In production and staging environments (wp_get_environment_type), results are cached for performance using a file-based cache.
- - Caching is skipped entirely when the constant `VIP_GO_APP_ENVIRONMENT` is defined.
+ - If a pre-built class cache is present it is read; otherwise classes are discovered live on every request. The runtime never writes the cache — see [Class caching](#class-caching) below.
3. Reflect on each discovered class and skip any that:
- are not instantiable,
- do not implement `TenupFramework\ModuleInterface`.
@@ -35,12 +34,16 @@ ModuleInitialization performs the following steps:
7. For each module, call `register()` only if `can_register()` returns true.
8. Store initialized modules for later retrieval.
-Environment cache behavior
-- Where cache lives: under the directory you pass to `init_classes()`, in a `class-loader-cache` folder (e.g., `YOUR_PLUGIN_INC . 'class-loader-cache'`).
-- When it’s used: only in `production` and `staging` environment types (`wp_get_environment_type()`).
-- How to clear: delete the `class-loader-cache` folder; it will be rebuilt on next discovery.
-- How to disable in development: use `development` or `local` environment types, or define `VIP_GO_APP_ENVIRONMENT` to skip the cache.
-- How to disable for hosts that don't support file-based caching: `define( 'TENUP_FRAMEWORK_DISABLE_CLASS_CACHE', true );` to skip caching altogether.
+## Class caching
+Caching the discovered class list is **optional and produced at build time**. The runtime only ever reads the cache; it never writes one, so a server cannot end up serving a stale cache it generated itself (the failure mode this model replaced — see [issue #30](https://github.com/10up/wp-framework/issues/30)).
+
+- Default (no cache file): classes are discovered live on every request. Correct, and the right default for small codebases — caching is opt-in.
+- With a cache file present: the framework reads it and skips discovery.
+- Where it lives: a `class-loader-cache` folder inside the directory you pass to `init_classes()`, e.g. `YOUR_PLUGIN_INC . 'class-loader-cache'`. The filename is versioned (`class-loader-cache-v2.php`) so a cache written by an older framework version is ignored after an upgrade rather than served stale; the old file is harmless cruft a clean deploy clears.
+- How to produce it: run `vendor/bin/tenup-framework-generate-class-cache ` (or `composer generate-class-cache -- `) in your build/deploy pipeline and ship the result as a build artefact.
+- How to bypass: define `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` as `true` to ignore any shipped cache and always discover live.
+
+Gitignore the `class-loader-cache` directory and regenerate it on every deploy. See [Build and Deployment](Build-and-Deployment.md) for CI examples and the per-package caching model, and [Debugging class loaders](Debugging.md) for the hidden admin page that shows what each cache is loading and flags stale ones.
Hooks
- Action: `tenup_framework_module_init__{slug}` — fires before each module’s `register()` runs.
diff --git a/docs/README.md b/docs/README.md
index 2cb283c..d959d9b 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -5,6 +5,7 @@ WP Framework is a lightweight set of building blocks for structuring WordPress p
- Base classes for custom and core post types
- Base class for taxonomies
- Asset helpers that read modern build sidecars (`.asset.php`)
+- Core modules for common WordPress functionality modifications
## Who is this for?
- External engineers — Start here: follow the Quick Start and then read Autoloading and Modules → Modules and Initialization → Post Types/Taxonomies → Asset Loading.
@@ -17,13 +18,18 @@ WP Framework is a lightweight set of building blocks for structuring WordPress p
3) Initialize modules: `TenupFramework\ModuleInitialization::instance()->init_classes( YOUR_PLUGIN_INC )`.
4) Implement small classes that implement `ModuleInterface` (use the `Module` trait) and optionally extend `AbstractPostType` / `AbstractTaxonomy`.
5) Load assets via the `GetAssetInfo` trait using dist/.asset.php sidecars.
+6) Optionally opt into Core modules for common WordPress functionality modifications.
## Table of Contents
+- [Upgrade Guide](Upgrade-Guide.md) — breaking changes and how to migrate (start here when updating a major version)
- [Autoloading and Modules](Autoloading.md) — how classes are discovered and initialized
- [Modules and Initialization](Modules-and-Initialization.md)
+- [Build and Deployment](Build-and-Deployment.md) — generating the class cache and wiring it into CI
+- [Debugging class loaders](Debugging.md) — the hidden admin page for inspecting caches
- [Post Types](Post-Types.md) — building custom and core post type integrations
- [Taxonomies](Taxonomies.md) — registering and configuring taxonomies
- [Asset Loading](Asset-Loading.md) — working with dist/.asset.php for dependencies and versioning
+- [Core Modules](Core-Modules.md) — WordPress functionality modification modules
## Conventions
- Namespaces: use your project namespace (e.g., `YourVendor\\YourPlugin`) for app code; reference framework classes via the TenupFramework namespace.
diff --git a/docs/Upgrade-Guide.md b/docs/Upgrade-Guide.md
new file mode 100644
index 0000000..30626a2
--- /dev/null
+++ b/docs/Upgrade-Guide.md
@@ -0,0 +1,204 @@
+# Upgrade Guide
+
+## Upgrading to 2.0
+
+2.0 is a **breaking release** with three independent breaking changes:
+
+1. **PHP 8.3 is now the minimum.** Composer will refuse to install on 8.2. See below.
+2. **The class-loader cache no longer generates automatically at runtime.** For the background on
+ *why*, see [issue #30](https://github.com/10up/wp-framework/issues/30) — the automatic runtime
+ cache could go stale on a server and could only be cleared by hand.
+3. **`get_name()` on the post type and taxonomy base classes declares a narrower return type.**
+ Static-analysis only, no runtime change. See below.
+
+This page covers what changed, who is affected, and exactly what to do.
+
+## PHP 8.3 is required
+
+`composer require 10up/wp-framework:^2.0` fails on PHP 8.2 with an unsatisfiable platform
+requirement. There is no partial upgrade path and no flag to work around it: the package source
+contains typed class constants, which are a **parse error** on 8.2, so a forced install would
+fatal at autoload time rather than degrade.
+
+**What to do:** confirm every environment that runs your project — local, CI, staging,
+production — is on PHP 8.3 or newer before upgrading.
+
+```bash
+php -v # locally
+wp --info # on the server, via WP-CLI
+```
+
+If any environment is still on 8.2, stay on `^1.2` until it is upgraded. 1.2.x continues to
+support 8.2.
+
+> **Why the requirement moved back up.** 1.2.0 lowered the minimum from 8.3 to 8.2
+> ([#8](https://github.com/10up/wp-framework/pull/8)). 2.0 raises it again because the framework
+> now declares native types on every class constant, which PHP only allows from 8.3. If the 8.2
+> floor matters more to your projects than static type coverage does, open an issue — the
+> trade-off is worth discussing rather than assuming.
+
+## `get_name()` declares a narrower return type
+
+`AbstractPostType::get_name()` and `AbstractTaxonomy::get_name()` keep their native `: string`
+signature, but the documented contract is now:
+
+```php
+/**
+ * @return lowercase-string&non-empty-string
+ */
+abstract public function get_name(): string;
+```
+
+This states what WordPress has always required of a post type or taxonomy key: non-empty and
+lowercase. `register_post_type()` and `register_taxonomy()` reject anything else.
+
+**Nothing changes at runtime.** No method signature changed, so no subclass breaks and no
+behaviour differs. This only affects static analysis.
+
+### Are you affected?
+
+Only if you run PHPStan or Psalm over your own project **and** a `get_name()` implementation
+returns a value the analyser cannot prove is lowercase and non-empty.
+
+Not flagged — a literal is provably both:
+
+```php
+public function get_name(): string {
+ return 'landing_page';
+}
+```
+
+Flagged — nothing narrows `$name`:
+
+```php
+public function get_name(): string {
+ return get_option( 'my_cpt_slug' ); // could be any string, or not a string at all
+}
+```
+
+**Fix it where the value is built, not at the return:**
+
+```php
+public function get_name(): string {
+ $name = strtolower( (string) get_option( 'my_cpt_slug' ) );
+
+ return '' !== $name ? $name : 'landing_page';
+}
+```
+
+`strtolower()` gives the analyser `lowercase-string`, and the `'' !== $name` check gives
+`non-empty-string`, so the contract is satisfied.
+
+If an implementation was returning a mixed-case or empty key, WordPress was already rejecting it.
+The tightened contract surfaces an existing bug rather than creating a new one.
+
+## Who is affected by the cache change
+
+You are affected if, on 1.x, you relied on the cache being **built automatically in production
+or staging**. After upgrading, that no longer happens — the framework reads a pre-built cache if
+one is present and otherwise **discovers classes live on every request**.
+
+- Small projects (a handful of Modules): running uncached is fine. No action needed beyond the
+ cleanup below.
+- Large codebases that want the performance of a cache: you must now **produce the cache as a
+ build step** (see [Build and Deployment](Build-and-Deployment.md)). If you upgrade without
+ adding that step, the site keeps working but runs uncached — slower discovery on every request.
+
+> There is no runtime warning when running uncached. To confirm whether a cache is actually in
+> use after upgrading, use the [loader debug page](Debugging.md) — see *Verify the upgrade* below.
+
+## What changed
+
+### Caching is now read-only at runtime and opt-in
+
+- **1.x:** the first request on a production/staging server discovered classes and **wrote** the
+ cache; later requests read it (and never re-checked it — the stale-cache bug).
+- **2.0:** the runtime **only ever reads** a cache. It never writes one. A cache is produced at
+ build time with the shipped command and shipped as a build artefact.
+
+### Removed
+
+- `should_use_cache()` and its environment gating.
+- The automatic `production` / `staging` caching behaviour (via `wp_get_environment_type()`).
+- `VIP_GO_APP_ENVIRONMENT` handling. It no longer affects caching. VIP sites already ran uncached
+ in practice, so there is nothing to do unless you want to opt into the build step.
+
+### Changed
+
+- **`TENUP_FRAMEWORK_DISABLE_CLASS_CACHE`** still disables caching, but its meaning is now "ignore
+ any shipped cache and always discover live." If you set it on 1.x to avoid caching, it keeps
+ doing what you want — no change needed.
+- The cache file name is versioned: `class-loader-cache/class-loader-cache-v2.php`. The runtime
+ looks only for this name, so a cache written by 1.x is ignored rather than served stale.
+
+## What you need to do
+
+### 1. Decide: uncached or build-time cache
+
+- **Run uncached (default):** do nothing. Discovery runs on every request. Correct, and fine for
+ most projects.
+- **Keep a cache:** add the generate step to your build/deploy pipeline. Full instructions and
+ CI examples (GitHub Actions, GitLab CI, CircleCI) are in
+ [Build and Deployment](Build-and-Deployment.md). In short:
+
+ ```bash
+ # Run after `composer install`, before deploy/packaging:
+ vendor/bin/tenup-framework-generate-class-cache inc/
+ ```
+
+ Pass the same directory you pass to `ModuleInitialization::init_classes()`. If you have several
+ plugins/themes using the framework, generate each one's cache (the command accepts multiple
+ directories).
+
+ > A `composer generate-class-cache` alias is convenient, but Composer does not expose a
+ > dependency's scripts to your project — add the alias to **your own** `composer.json` if you
+ > want it. The `vendor/bin` command is the portable entry point. See
+ > [Build and Deployment](Build-and-Deployment.md#composer-alias).
+
+### 2. Clean up old cache files
+
+The cache directory is a build artefact, not source. Add it to `.gitignore` and let a clean
+deploy clear stale copies:
+
+```gitignore
+# Generated class-loader cache (built in CI, shipped with the deploy)
+**/class-loader-cache/
+```
+
+Any old `discoverer-cache-*` file left by 1.x is harmless — the 2.0 runtime never reads it — but
+you can delete the `class-loader-cache` directory to remove the clutter; it will be regenerated by
+your build if you opted into caching.
+
+### 3. Verify the upgrade
+
+Because nothing in the runtime announces whether caching is on, confirm it explicitly with the
+[loader debug page](Debugging.md):
+
+```
+/wp-admin/admin.php?page=tenup-framework-loaders
+```
+
+For each loader it shows a **Cache status** line:
+
+- *"discovering live on every request"* — no cache is in use (expected if you didn't add the build
+ step).
+- *"Cache in use …"* — a pre-built cache is being read (expected after wiring in the build step).
+
+Use the per-loader **staleness check** to confirm a shipped cache matches what's on disk.
+
+## Reference
+
+| 1.x | 2.0 |
+| --- | --- |
+| PHP 8.2+ | **PHP 8.3+** |
+| `get_name(): string` on the post type / taxonomy base classes | Same signature, contract narrowed to `lowercase-string&non-empty-string` (static analysis only) |
+| Cache written automatically at runtime (production/staging) | Cache produced at build time only; runtime reads, never writes |
+| `should_use_cache()` gates writing | Removed |
+| `VIP_GO_APP_ENVIRONMENT` skips caching | No effect (removed) |
+| `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` skips the auto-cache | Ignores any shipped cache, always discovers live |
+| Cache at `class-loader-cache/discoverer-cache-TenupFramework` | Cache at `class-loader-cache/class-loader-cache-v2.php` |
+
+## See also
+- [Build and Deployment](Build-and-Deployment.md) — generating the cache and CI examples
+- [Debugging class loaders](Debugging.md) — verifying cache state on a running site
+- [Modules and Initialization](Modules-and-Initialization.md)
diff --git a/fixtures/assets/dist/blocks/test-block.asset.php b/fixtures/assets/dist/blocks/test-block.asset.php
new file mode 100644
index 0000000..abdc30f
--- /dev/null
+++ b/fixtures/assets/dist/blocks/test-block.asset.php
@@ -0,0 +1 @@
+ array( 'test-block-deps' ), 'version' => 'test-block-version');
diff --git a/fixtures/classes/PostTypes/Demo.php b/fixtures/classes/PostTypes/Demo.php
index 410cf46..8e5c515 100644
--- a/fixtures/classes/PostTypes/Demo.php
+++ b/fixtures/classes/PostTypes/Demo.php
@@ -21,7 +21,7 @@ class Demo extends AbstractPostType {
*
* @return string
*/
- public function get_name() {
+ public function get_name(): string {
return 'tenup-demo';
}
@@ -30,7 +30,7 @@ public function get_name() {
*
* @return string
*/
- public function get_singular_label() {
+ public function get_singular_label(): string {
return esc_html__( 'Demo', 'tenup-plugin' );
}
@@ -39,7 +39,7 @@ public function get_singular_label() {
*
* @return string
*/
- public function get_plural_label() {
+ public function get_plural_label(): string {
return esc_html__( 'Demos', 'tenup-plugin' );
}
@@ -52,7 +52,7 @@ public function get_plural_label() {
*
* @return string
*/
- public function get_menu_icon() {
+ public function get_menu_icon(): string {
return 'dashicons-chart-pie';
}
@@ -61,7 +61,7 @@ public function get_menu_icon() {
*
* @return bool
*/
- public function can_register() {
+ public function can_register(): bool {
return true;
}
@@ -71,7 +71,7 @@ public function can_register() {
*
* @return array
*/
- public function get_supported_taxonomies() {
+ public function get_supported_taxonomies(): array {
return [
'tenup-tax-demo',
];
@@ -82,7 +82,7 @@ public function get_supported_taxonomies() {
*
* @return void
*/
- public function after_register() {
+ public function after_register(): void {
// Register any hooks/filters you need.
}
}
diff --git a/fixtures/classes/PostTypes/Page.php b/fixtures/classes/PostTypes/Page.php
index fa360d4..7815534 100644
--- a/fixtures/classes/PostTypes/Page.php
+++ b/fixtures/classes/PostTypes/Page.php
@@ -24,7 +24,7 @@ class Page extends AbstractCorePostType {
*
* @return string
*/
- public function get_name() {
+ public function get_name(): string {
return 'page';
}
@@ -34,7 +34,7 @@ public function get_name() {
*
* @return array
*/
- public function get_supported_taxonomies() {
+ public function get_supported_taxonomies(): array {
return [];
}
@@ -43,7 +43,7 @@ public function get_supported_taxonomies() {
*
* @return void
*/
- public function after_register() {
+ public function after_register(): void {
// Do nothing.
}
}
diff --git a/fixtures/classes/PostTypes/Post.php b/fixtures/classes/PostTypes/Post.php
index ce4a28c..21c93ab 100644
--- a/fixtures/classes/PostTypes/Post.php
+++ b/fixtures/classes/PostTypes/Post.php
@@ -24,7 +24,7 @@ class Post extends AbstractCorePostType {
*
* @return string
*/
- public function get_name() {
+ public function get_name(): string {
return 'post';
}
@@ -36,7 +36,7 @@ public function get_name() {
*
* @return array
*/
- public function get_supported_taxonomies() {
+ public function get_supported_taxonomies(): array {
return [];
}
@@ -45,7 +45,7 @@ public function get_supported_taxonomies() {
*
* @return void
*/
- public function after_register() {
+ public function after_register(): void {
// Do nothing.
}
}
diff --git a/fixtures/classes/Standalone/Standalone.php b/fixtures/classes/Standalone/Standalone.php
index d52c2b8..94bfdb7 100644
--- a/fixtures/classes/Standalone/Standalone.php
+++ b/fixtures/classes/Standalone/Standalone.php
@@ -27,7 +27,7 @@ public function __construct() {
*
* @return void
*/
- public function init() {
+ public function init(): void {
echo 'Hello from the Standalone class!';
}
}
diff --git a/fixtures/classes/Taxonomies/Demo.php b/fixtures/classes/Taxonomies/Demo.php
index de86cc6..281dc6b 100644
--- a/fixtures/classes/Taxonomies/Demo.php
+++ b/fixtures/classes/Taxonomies/Demo.php
@@ -21,7 +21,7 @@ class Demo extends AbstractTaxonomy {
*
* @return string
*/
- public function get_name() {
+ public function get_name(): string {
return 'tenup-tax-demo';
}
@@ -30,7 +30,7 @@ public function get_name() {
*
* @return string
*/
- public function get_singular_label() {
+ public function get_singular_label(): string {
return esc_html__( 'Demo Term', 'tenup-plugin' );
}
@@ -39,7 +39,7 @@ public function get_singular_label() {
*
* @return string
*/
- public function get_plural_label() {
+ public function get_plural_label(): string {
return esc_html__( 'Demo Terms', 'tenup-plugin' );
}
@@ -48,7 +48,7 @@ public function get_plural_label() {
*
* @return bool
*/
- public function can_register() {
+ public function can_register(): bool {
return true;
}
}
diff --git a/phpcs.xml b/phpcs.xml
index dac99b7..59bfa01 100644
--- a/phpcs.xml
+++ b/phpcs.xml
@@ -17,6 +17,18 @@
+
+
+ */tests/*
+
+
+ */tests/*
+
+
diff --git a/phpstan.neon b/phpstan.neon
index 4d9795d..98359b9 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -1,6 +1,7 @@
includes:
- vendor/szepeviktor/phpstan-wordpress/extension.neon
- vendor/phpstan/phpstan-deprecation-rules/rules.neon
+ - vendor/tomasvotruba/type-coverage/config/extension.neon
parameters:
paths:
@@ -14,5 +15,8 @@ parameters:
# Uses func_get_args()
- '#^Function apply_filters invoked with [34567] parameters, 2 required\.$#'
- '#^Function remove_filter invoked with [34567] parameters, 2-3 required\.$#'
- # Remove issues that come from using array as a type rather than string[] or array etc.
- - '#no value type specified in iterable type array#'
+ type_coverage:
+ return: 99
+ param: 99
+ property: 99
+ constant: 99
diff --git a/rector.php b/rector.php
new file mode 100644
index 0000000..fac46ff
--- /dev/null
+++ b/rector.php
@@ -0,0 +1,27 @@
+withPaths([
+ __DIR__ . '/src',
+ __DIR__ . '/fixtures',
+ ])
+ ->withSkip([
+ __DIR__ . '/**/node_modules/**',
+ __DIR__ . '/**/vendor/**',
+ __DIR__ . '/**/dist/**',
+
+ ])
+ ->withPhpSets(php83: true)
+ ->withTypeCoverageLevel(49)
+ ->withSkip([
+ Rector\Php81\Rector\Array_\FirstClassCallableRector::class,
+ Rector\Php83\Rector\ClassMethod\AddOverrideAttributeToOverriddenMethodsRector::class,
+ Rector\Php70\Rector\StmtsAwareInterface\IfIssetToCoalescingRector::class,
+ Rector\Php53\Rector\Ternary\TernaryToElvisRector::class,
+ Rector\Php81\Rector\Property\ReadOnlyPropertyRector::class,
+ ]);
diff --git a/src/Assets/GetAssetInfo.php b/src/Assets/GetAssetInfo.php
index 5241f31..ef24266 100644
--- a/src/Assets/GetAssetInfo.php
+++ b/src/Assets/GetAssetInfo.php
@@ -23,24 +23,22 @@ trait GetAssetInfo {
*
* @var ?string
*/
- public $dist_path = null;
+ public ?string $dist_path = null;
/**
* Fallback version to use if asset file is not found
*
* @var ?string
*/
- public $fallback_version = null;
+ public ?string $fallback_version = null;
/**
* Setup asset variables
*
* @param string $dist_path Path to the dist directory
* @param string $fallback_version Fallback version to use if asset file is not found
- *
- * @return void
*/
- public function setup_asset_vars( string $dist_path, string $fallback_version ) {
+ public function setup_asset_vars( string $dist_path, string $fallback_version ): void {
$this->dist_path = trailingslashit( $dist_path );
$this->fallback_version = $fallback_version;
}
@@ -55,13 +53,15 @@ public function setup_asset_vars( string $dist_path, string $fallback_version )
*
* @return string|($attribute is null ? array{version: string, dependencies: array} : $attribute is'dependencies' ? array : string)
*/
- public function get_asset_info( string $slug, ?string $attribute = null ) {
+ public function get_asset_info( string $slug, ?string $attribute = null ): string|array {
if ( is_null( $this->dist_path ) || is_null( $this->fallback_version ) ) {
throw new RuntimeException( 'Asset variables not set. Please run setup_asset_vars() before calling get_asset_info().' );
}
- if ( file_exists( $this->dist_path . 'js/' . $slug . '.asset.php' ) ) {
+ if ( file_exists( $this->dist_path . $slug . '.asset.php' ) ) {
+ $asset = require $this->dist_path . $slug . '.asset.php';
+ } elseif ( file_exists( $this->dist_path . 'js/' . $slug . '.asset.php' ) ) {
$asset = require $this->dist_path . 'js/' . $slug . '.asset.php';
} elseif ( file_exists( $this->dist_path . 'css/' . $slug . '.asset.php' ) ) {
$asset = require $this->dist_path . 'css/' . $slug . '.asset.php';
diff --git a/src/BlockRegistrar.php b/src/BlockRegistrar.php
new file mode 100644
index 0000000..504573e
--- /dev/null
+++ b/src/BlockRegistrar.php
@@ -0,0 +1,330 @@
+
+ */
+ public static array $registered_block_names = [];
+
+ /**
+ * Static array to track block registration sources for conflict detection.
+ *
+ * @var array Block name => source class
+ */
+ public static array $block_sources = [];
+
+ /**
+ * Whether the allowed_block_types_all filter has been registered.
+ *
+ * @var bool
+ */
+ public static bool $filter_registered = false;
+
+ /**
+ * Get the blocks directory paths.
+ *
+ * @return array Array of paths to the blocks directories.
+ */
+ abstract public function get_blocks_directory(): array;
+
+ /**
+ * Can this module be registered?
+ *
+ * @return bool
+ */
+ public function can_register(): bool {
+ return true;
+ }
+
+ /**
+ * Register hooks.
+ *
+ * @return void
+ */
+ public function register(): void {
+ add_action( 'init', [ $this, 'register_blocks' ], 10, 0 );
+ }
+
+ /**
+ * Automatically registers all blocks from the blocks directories.
+ *
+ * @return void
+ */
+ public function register_blocks(): void {
+ // Check if WordPress and block editor are available
+ if ( ! function_exists( 'register_block_type_from_metadata' ) ) {
+ error_log( 'BlockRegistrar: WordPress block editor not available' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ return;
+ }
+
+ $blocks_dirs = $this->get_blocks_directory();
+ $block_names = [];
+ $errors = [];
+
+ foreach ( $blocks_dirs as $blocks_dir ) {
+ // Validate directory path
+ $validated_dir = $this->validate_directory_path( $blocks_dir );
+ if ( ! $validated_dir ) {
+ $errors[] = "Invalid directory path: {$blocks_dir}";
+ continue;
+ }
+
+ if ( ! file_exists( $validated_dir ) ) {
+ continue;
+ }
+
+ // Check if directory is readable
+ if ( ! is_readable( $validated_dir ) ) {
+ $errors[] = "Directory not readable: {$validated_dir}";
+ continue;
+ }
+
+ $block_json_files = glob( $validated_dir . '*/block.json' );
+ if ( empty( $block_json_files ) ) {
+ continue;
+ }
+
+ foreach ( $block_json_files as $filename ) {
+ $block_folder = dirname( $filename );
+
+ // Validate block.json file
+ $block_metadata = $this->validate_block_json( $filename );
+ if ( ! $block_metadata ) {
+ $errors[] = "Invalid block.json: {$filename}";
+ continue;
+ }
+
+ $block_options = $this->get_block_options( $block_folder );
+
+ /**
+ * Block registration options with proper typing for WordPress function.
+ *
+ * @var array{api_version?: string, title?: string, category?: string|null, parent?: array|null, ancestor?: array|null, allowed_blocks?: array|null, icon?: string|null, description?: string, render_callback?: callable} $block_options
+ */
+ $block = register_block_type_from_metadata( $block_folder, $block_options );
+ if ( ! $block ) {
+ $errors[] = "Failed to register block: {$block_folder}";
+ continue;
+ }
+
+ // Check for block name conflicts
+ $block_name = $block->name;
+ $current_class = get_class( $this );
+
+ if ( isset( self::$block_sources[ $block_name ] ) ) {
+ $existing_source = self::$block_sources[ $block_name ];
+
+ // Log the conflict
+ error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ sprintf(
+ 'BlockRegistrar: Block name conflict detected. Block "%s" already registered by "%s", attempted to register by "%s"',
+ $block_name,
+ $existing_source,
+ $current_class
+ )
+ );
+
+ // Skip adding to allowed blocks to prevent conflicts
+ continue;
+ }
+
+ // Track the block source
+ self::$block_sources[ $block_name ] = $current_class;
+ $block_names[] = $block_name;
+ }
+ }
+
+ // Log any errors that occurred
+ if ( ! empty( $errors ) ) {
+ error_log( 'BlockRegistrar errors: ' . implode( '; ', $errors ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ }
+
+ if ( ! empty( $block_names ) ) {
+ $this->register_allowed_block_types( $block_names );
+ }
+ }
+
+ /**
+ * Get block registration options for a specific block folder.
+ *
+ * @param string $block_folder The path to the block folder.
+ * @return array Block registration options.
+ */
+ protected function get_block_options( string $block_folder ): array {
+ $block_options = [];
+
+ $markup_file_path = $block_folder . '/markup.php';
+ if ( file_exists( $markup_file_path ) ) {
+ // Only add the render callback if the block has a file called markup.php in its directory
+ $block_options['render_callback'] = function ( array $attributes, string $content, \WP_Block $block ) use ( $block_folder ): string {
+ // Create helpful variables that will be accessible in markup.php file
+ $context = $block->context;
+
+ // Get the actual markup from the markup.php file
+ ob_start();
+ include $block_folder . '/markup.php';
+ $output = ob_get_clean();
+ return is_string( $output ) ? $output : '';
+ };
+ }
+
+ return $block_options;
+ }
+
+ /**
+ * Register blocks in allowed_block_types_all filter.
+ *
+ * @param array $block_names Array of block names to allow.
+ * @return void
+ */
+ protected function register_allowed_block_types( array $block_names ): void {
+ // Add new block names to the static registry, avoiding duplicates
+ foreach ( $block_names as $block_name ) {
+ if ( ! in_array( $block_name, self::$registered_block_names, true ) ) {
+ self::$registered_block_names[] = $block_name;
+ }
+ }
+
+ // Only register the filter once, regardless of how many instances exist
+ if ( ! self::$filter_registered ) {
+ add_filter(
+ 'allowed_block_types_all',
+ [ self::class, 'filter_allowed_block_types' ]
+ );
+ self::$filter_registered = true;
+ }
+ }
+
+ /**
+ * Static callback for the allowed_block_types_all filter.
+ *
+ * @param array|bool $allowed_blocks Current allowed blocks.
+ * @return array|bool Modified allowed blocks.
+ */
+ public static function filter_allowed_block_types( array|bool $allowed_blocks ): array|bool {
+ if ( ! is_array( $allowed_blocks ) ) {
+ return $allowed_blocks;
+ }
+
+ return array_merge( $allowed_blocks, self::$registered_block_names );
+ }
+
+ /**
+ * Check if a block name has a conflict.
+ *
+ * @param string $block_name The block name to check.
+ * @return bool True if there's a conflict, false otherwise.
+ */
+ public static function has_block_conflict( string $block_name ): bool {
+ return isset( self::$block_sources[ $block_name ] );
+ }
+
+ /**
+ * Get the source class for a registered block.
+ *
+ * @param string $block_name The block name.
+ * @return string|null The source class name or null if not found.
+ */
+ public static function get_block_source( string $block_name ): ?string {
+ return self::$block_sources[ $block_name ] ?? null;
+ }
+
+ /**
+ * Get all registered block names and their sources.
+ *
+ * @return array Block name => source class.
+ */
+ public static function get_all_block_sources(): array {
+ return self::$block_sources;
+ }
+
+ /**
+ * Validate directory path for security and correctness.
+ *
+ * @param string $path The directory path to validate.
+ * @return string|false Validated path or false if invalid.
+ */
+ protected function validate_directory_path( string $path ): string|false {
+ // Check for empty or null paths
+ if ( empty( $path ) ) {
+ return false;
+ }
+
+ // Check for directory traversal attacks
+ if ( str_contains( $path, '..' ) || str_contains( $path, './' ) ) {
+ return false;
+ }
+
+ // Normalize path separators
+ $path = str_replace( '\\', '/', $path );
+
+ // Ensure path ends with directory separator
+ if ( ! str_ends_with( $path, '/' ) ) {
+ $path .= '/';
+ }
+
+ // Check for reasonable path length (prevent excessive memory usage)
+ if ( strlen( $path ) > 1000 ) {
+ return false;
+ }
+
+ return $path;
+ }
+
+ /**
+ * Validate block.json file and return metadata.
+ *
+ * @param string $file_path Path to block.json file.
+ * @return array|false Block metadata or false if invalid.
+ */
+ protected function validate_block_json( string $file_path ): array|false {
+ // Check if file exists and is readable
+ if ( ! file_exists( $file_path ) || ! is_readable( $file_path ) ) {
+ return false;
+ }
+
+ // Read and decode JSON
+ // This approach avoids file_get_contents() which is a PHPCS issue
+ ob_start();
+ include $file_path;
+ $json_content = ob_get_clean();
+
+ if ( empty( $json_content ) ) {
+ return false;
+ }
+
+ $metadata = json_decode( $json_content, true );
+ if ( json_last_error() !== JSON_ERROR_NONE ) {
+ return false;
+ }
+
+ // Validate required fields
+ if ( ! is_array( $metadata ) || ! isset( $metadata['name'] ) || ! is_string( $metadata['name'] ) ) {
+ return false;
+ }
+
+ // Validate block name format (namespace/name)
+ if ( ! preg_match( '/^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/', $metadata['name'] ) ) {
+ return false;
+ }
+
+ return $metadata;
+ }
+}
diff --git a/src/Cache/ReadOnlyFileDiscoverCacheDriver.php b/src/Cache/ReadOnlyFileDiscoverCacheDriver.php
new file mode 100644
index 0000000..8757406
--- /dev/null
+++ b/src/Cache/ReadOnlyFileDiscoverCacheDriver.php
@@ -0,0 +1,65 @@
+directory = rtrim( $directory, '/' );
+ $this->serialize = $serialize;
+ $this->filename = $filename;
+ }
+
+ /**
+ * No-op. Cache generation happens at build time only, never at runtime.
+ *
+ * @param string $id The cache identifier.
+ * @param array $discovered The discovered structures.
+ *
+ * @return void
+ */
+ public function put( string $id, array $discovered ): void {
+ // Intentionally empty.
+ }
+
+ /**
+ * No-op. The runtime never deletes the cache.
+ *
+ * @param string $id The cache identifier.
+ *
+ * @return void
+ */
+ public function forget( string $id ): void {
+ // Intentionally empty.
+ }
+}
diff --git a/src/Core/Emoji.php b/src/Core/Emoji.php
new file mode 100644
index 0000000..f6f7d32
--- /dev/null
+++ b/src/Core/Emoji.php
@@ -0,0 +1,112 @@
+ $plugins An array of default TinyMCE plugins.
+ * @return array An array of TinyMCE plugins, without wpemoji.
+ */
+ public function disable_emojis_tinymce( array $plugins ): array {
+ if ( in_array( 'wpemoji', $plugins, true ) ) {
+ return array_diff( $plugins, [ 'wpemoji' ] );
+ }
+
+ return $plugins;
+ }
+
+ /**
+ * Remove emoji CDN hostname from DNS prefetching hints.
+ *
+ * @link https://developer.wordpress.org/reference/hooks/emoji_svg_url/
+ *
+ * Typed for the common case of a plain list of URL strings. The `wp_resource_hints`
+ * contract also permits an entry to be an array of link attributes; array_diff() below
+ * would coerce such an entry to string and warn. No core or common plugin path produces
+ * one before this callback runs, so the narrower type documents the assumption rather
+ * than widening every caller for a case that does not occur in practice.
+ *
+ * @param array $urls URLs to print for resource hints.
+ * @param string $relation_type The relation type the URLs are printed for.
+ * @return array Difference between the two arrays.
+ */
+ public function disable_emoji_dns_prefetch( array $urls, string $relation_type ): array {
+ if ( 'dns-prefetch' === $relation_type ) {
+ /** This filter is documented in wp-includes/formatting.php */
+ $emoji_svg_url = apply_filters( 'emoji_svg_url', 'https://s.w.org/images/core/emoji/2/svg/' );
+
+ // The filter can return anything. Only a string names a URL to remove; for any
+ // other value there is nothing to take out of the hints, so leave them untouched.
+ if ( is_string( $emoji_svg_url ) ) {
+ $urls = array_values( array_diff( $urls, [ $emoji_svg_url ] ) );
+ }
+ }
+
+ return $urls;
+ }
+}
diff --git a/src/Core/HeadOverrides.php b/src/Core/HeadOverrides.php
new file mode 100644
index 0000000..b5c71b7
--- /dev/null
+++ b/src/Core/HeadOverrides.php
@@ -0,0 +1,55 @@
+>
+ */
+ protected static array $loaders = [];
+
+ /**
+ * Whether this copy has wired its WordPress hooks yet.
+ *
+ * @var bool
+ */
+ protected static bool $booted = false;
+
+ /**
+ * Record a loader and ensure the admin hooks are wired.
+ *
+ * Called from ModuleInitialization::init_classes() (admin requests only). Returns early
+ * without storing anything or adding hooks when the tooling is disabled.
+ *
+ * @param array $record The loader record.
+ *
+ * @return void
+ */
+ public static function record( array $record ): void {
+ if ( ! self::is_enabled() ) {
+ return;
+ }
+
+ // Keep one record per directory: if init_classes() runs more than once for the same
+ // directory in a request, the latest call (with fresh timing) replaces the earlier one
+ // rather than producing a duplicate card.
+ $directory = isset( $record['directory'] ) && is_string( $record['directory'] ) ? $record['directory'] : '';
+ foreach ( self::$loaders as $index => $existing ) {
+ if ( ( $existing['directory'] ?? null ) === $directory ) {
+ self::$loaders[ $index ] = $record;
+ self::boot();
+ return;
+ }
+ }
+
+ self::$loaders[] = $record;
+
+ self::boot();
+ }
+
+ /**
+ * The loader records collected for this framework copy (not the cross-copy aggregate —
+ * use the `tenup_framework_debug_loaders` filter for that).
+ *
+ * @return array>
+ */
+ public static function get_loaders(): array {
+ return self::$loaders;
+ }
+
+ /**
+ * Whether the debug tooling is enabled.
+ *
+ * Off when WordPress isn't loaded, when the disable constant is set, or when a filter turns
+ * it off. On by default (in the admin, which is the only place record() is called).
+ *
+ * @return bool
+ */
+ public static function is_enabled(): bool {
+ if ( ! function_exists( 'add_action' ) ) {
+ return false;
+ }
+
+ if ( defined( 'TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG' ) && true === constant( 'TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG' ) ) {
+ return false;
+ }
+
+ return (bool) apply_filters( 'tenup_framework_enable_loader_debug', true );
+ }
+
+ /**
+ * Wire the WordPress hooks once per copy: contribute this copy's records to the shared
+ * filter, and register the admin page a single time across every loaded copy.
+ *
+ * @return void
+ */
+ protected static function boot(): void {
+ if ( self::$booted ) {
+ return;
+ }
+ self::$booted = true;
+
+ add_filter(
+ self::FILTER,
+ static function ( mixed $loaders ) {
+ return array_merge( (array) $loaders, self::$loaders );
+ }
+ );
+
+ // Register the page only once, even when several framework copies are loaded.
+ if ( empty( $GLOBALS['tenup_framework_debug_page_registered'] ) ) {
+ $GLOBALS['tenup_framework_debug_page_registered'] = true;
+ add_action( 'admin_menu', [ self::class, 'register_page' ] );
+ }
+ }
+
+ /**
+ * Register the hidden admin page.
+ *
+ * An empty parent slug keeps the page out of every menu while leaving it reachable at
+ * admin.php?page=tenup-framework-loaders.
+ *
+ * @return void
+ */
+ public static function register_page(): void {
+ $title = __( 'WP Framework Loaders', 'tenup-framework' );
+
+ add_submenu_page(
+ '',
+ $title,
+ $title,
+ self::CAPABILITY,
+ self::PAGE_SLUG,
+ [ self::class, 'render_page' ]
+ );
+ }
+
+ /**
+ * Render the page.
+ *
+ * @return void
+ */
+ public static function render_page(): void {
+ if ( ! current_user_can( self::CAPABILITY ) ) {
+ wp_die( esc_html__( 'You do not have permission to view this page.', 'tenup-framework' ) );
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only diagnostic; the value is nonce-verified below before use.
+ $requested_check = ( isset( $_GET['check'] ) && is_string( $_GET['check'] ) ) ? sanitize_text_field( wp_unslash( $_GET['check'] ) ) : '';
+ $check = self::check_is_valid( $requested_check ) ? $requested_check : '';
+
+ $loaders = apply_filters( self::FILTER, [] );
+ if ( ! is_array( $loaders ) ) {
+ $loaders = [];
+ }
+
+ echo '';
+ self::render_styles();
+ echo '
' . esc_html__( 'WP Framework Loaders', 'tenup-framework' ) . '
';
+ echo '
' . esc_html__( 'Each card is a directory passed to ModuleInitialization::init_classes(), with the state of its class-loader cache. Caches are built at deploy time and read — never written — at runtime.', 'tenup-framework' ) . '
';
+
+ if ( empty( $loaders ) ) {
+ echo '
' . esc_html__( 'No class loaders were recorded for this request.', 'tenup-framework' ) . '
';
+ echo '
';
+ return;
+ }
+
+ foreach ( $loaders as $loader ) {
+ if ( is_array( $loader ) ) {
+ self::render_loader( $loader, $check );
+ }
+ }
+
+ echo '';
+ }
+
+ /**
+ * Render a single loader block.
+ *
+ * @param array $loader The loader record.
+ * @param string $check The validated staleness-check token, if any.
+ *
+ * @return void
+ */
+ protected static function render_loader( array $loader, string $check ): void {
+ $directory = self::to_string( $loader['directory'] ?? '' );
+ $cache_file = self::to_string( $loader['cache_file'] ?? '' );
+ $classes = isset( $loader['classes'] ) && is_array( $loader['classes'] )
+ ? array_values( array_map( [ self::class, 'to_string' ], $loader['classes'] ) )
+ : [];
+
+ $state = self::cache_state( $loader );
+ $legacy = self::legacy_files( $cache_file );
+
+ echo '';
+
+ echo '
';
+ echo '
' . esc_html( self::owner_label( $directory ) ) . '
';
+ echo '' . esc_html( $state['badge'] ) . '';
+ echo '';
+
+ if ( '' !== $state['note'] ) {
+ echo '
' . esc_html( $state['note'] ) . '
';
+ }
+
+ if ( ! empty( $legacy ) ) {
+ echo '
' . esc_html(
+ sprintf(
+ /* translators: %s: comma-separated list of unexpected filenames. */
+ __( 'Unexpected files in the cache directory, likely left by an older version: %s. Delete them or redeploy.', 'tenup-framework' ),
+ implode( ', ', $legacy )
+ )
+ ) . '
';
+ }
+
+ echo '
';
+
+ echo '
';
+ echo '' . esc_html(
+ sprintf(
+ /* translators: %d: number of classes. */
+ _n( '%d class loaded', '%d classes loaded', count( $classes ), 'tenup-framework' ),
+ count( $classes )
+ )
+ ) . '
';
+ self::render_classes( $classes );
+ echo ' ';
+
+ self::render_staleness( $directory, $classes, $check );
+
+ echo '
';
+ }
+
+ /**
+ * Render a label/value row, escaping both.
+ *
+ * @param string $label The row label.
+ * @param string $value The row value.
+ *
+ * @return void
+ */
+ protected static function render_row( string $label, string $value ): void {
+ echo '| ' . esc_html( $label ) . ' | ' . esc_html( $value ) . ' |
';
+ }
+
+ /**
+ * Render the list of loaded classes with the file each resolves to.
+ *
+ * @param array $classes The class names.
+ *
+ * @return void
+ */
+ protected static function render_classes( array $classes ): void {
+ if ( empty( $classes ) ) {
+ return;
+ }
+
+ echo '';
+ echo '| ' . esc_html__( 'Class', 'tenup-framework' ) . ' | ' . esc_html__( 'File', 'tenup-framework' ) . ' |
';
+
+ $module_init = ModuleInitialization::instance();
+
+ foreach ( $classes as $class ) {
+ $reflection = $module_init->get_fully_loadable_class( $class );
+ $file = $reflection ? (string) $reflection->getFileName() : __( 'Does not resolve — likely a stale cache entry.', 'tenup-framework' );
+
+ echo '' . esc_html( $class ) . ' | ' . esc_html( $file ) . ' |
';
+ }
+
+ echo '
';
+ }
+
+ /**
+ * Render the on-demand staleness check: a trigger link, and the diff when requested.
+ *
+ * @param string $directory The loader directory.
+ * @param array $classes The currently loaded class list.
+ * @param string $check The validated staleness-check token, if any.
+ *
+ * @return void
+ */
+ protected static function render_staleness( string $directory, array $classes, string $check ): void {
+ if ( '' === $directory ) {
+ return;
+ }
+
+ if ( self::token_for( $directory ) !== $check ) {
+ $url = wp_nonce_url(
+ add_query_arg(
+ [
+ 'page' => self::PAGE_SLUG,
+ 'check' => self::token_for( $directory ),
+ ],
+ admin_url( 'admin.php' )
+ ),
+ self::CHECK_NONCE
+ );
+
+ echo '' . esc_html__( 'Check this cache for staleness', 'tenup-framework' ) . '
';
+ return;
+ }
+
+ $live_start = hrtime( true );
+ $live = ModuleInitialization::instance()->discover_live( $directory );
+ $live_seconds = ( hrtime( true ) - $live_start ) / 1e9;
+
+ $loaded = array_values( $classes );
+ $removed = array_diff( $loaded, $live ); // In cache but no longer on disk.
+ $added = array_diff( $live, $loaded ); // On disk but missing from the cache.
+
+ $timing = sprintf(
+ /* translators: %s: formatted duration. */
+ __( 'Live discovery took %s.', 'tenup-framework' ),
+ self::format_duration( $live_seconds )
+ );
+
+ if ( empty( $removed ) && empty( $added ) ) {
+ echo '' . esc_html__( 'Up to date — the cache matches a live scan.', 'tenup-framework' ) . ' ' . esc_html( $timing ) . '
';
+ return;
+ }
+
+ echo '';
+ echo '
' . esc_html__( 'Stale — the cache differs from a live scan.', 'tenup-framework' ) . ' ' . esc_html( $timing );
+
+ if ( ! empty( $added ) ) {
+ echo '
' . esc_html__( 'On disk but missing from the cache:', 'tenup-framework' ) . '
';
+ foreach ( $added as $class ) {
+ echo '' . esc_html( $class ) . ' ';
+ }
+ echo '
';
+ }
+
+ if ( ! empty( $removed ) ) {
+ echo '
' . esc_html__( 'In the cache but no longer on disk:', 'tenup-framework' ) . '
';
+ foreach ( $removed as $class ) {
+ echo '' . esc_html( $class ) . ' ';
+ }
+ echo '
';
+ }
+
+ echo '
' . esc_html__( 'Regenerate the cache in your build (composer generate-class-cache) or remove the file and redeploy.', 'tenup-framework' ) . '
';
+ echo '
';
+ }
+
+ /**
+ * Coerce a mixed value (loader records arrive through a filter as mixed) to a string,
+ * returning an empty string for anything non-scalar.
+ *
+ * @param mixed $value The value to coerce.
+ *
+ * @return string
+ */
+ protected static function to_string( mixed $value ): string {
+ return is_scalar( $value ) ? (string) $value : '';
+ }
+
+ /**
+ * Format a duration in seconds for display, choosing a sensible unit. Values arrive through
+ * a filter as mixed, so anything non-numeric or non-positive renders as a placeholder.
+ *
+ * @param mixed $seconds The duration in seconds.
+ *
+ * @return string
+ */
+ protected static function format_duration( mixed $seconds ): string {
+ $seconds = is_numeric( $seconds ) ? (float) $seconds : 0.0;
+
+ // Values arrive through the cross-copy filter as mixed, so reject non-positive and
+ // non-finite (INF/NAN) input rather than rendering "inf s" / "nan s".
+ if ( $seconds <= 0.0 || ! is_finite( $seconds ) ) {
+ return '—';
+ }
+
+ $milliseconds = $seconds * 1000;
+
+ if ( $milliseconds < 1 ) {
+ /* translators: %s: duration in milliseconds. */
+ return sprintf( __( '%s ms', 'tenup-framework' ), number_format( $milliseconds, 3 ) );
+ }
+
+ if ( $milliseconds < 1000 ) {
+ /* translators: %s: duration in milliseconds. */
+ return sprintf( __( '%s ms', 'tenup-framework' ), number_format( $milliseconds, 2 ) );
+ }
+
+ /* translators: %s: duration in seconds. */
+ return sprintf( __( '%s s', 'tenup-framework' ), number_format( $seconds, 2 ) );
+ }
+
+ /**
+ * A stable, opaque token identifying a loader directory in the check link.
+ *
+ * @param string $directory The loader directory.
+ *
+ * @return string
+ */
+ protected static function token_for( string $directory ): string {
+ return md5( $directory );
+ }
+
+ /**
+ * Whether a requested check token is well-formed and the request is nonce-verified.
+ *
+ * @param string $token The requested token.
+ *
+ * @return bool
+ */
+ protected static function check_is_valid( string $token ): bool {
+ if ( '' === $token ) {
+ return false;
+ }
+
+ $nonce = ( isset( $_GET['_wpnonce'] ) && is_string( $_GET['_wpnonce'] ) ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
+
+ return (bool) wp_verify_nonce( $nonce, self::CHECK_NONCE );
+ }
+
+ /**
+ * A human-friendly owner label for a directory (plugin/theme name where derivable).
+ *
+ * @param string $directory The loader directory.
+ *
+ * @return string
+ */
+ protected static function owner_label( string $directory ): string {
+ if ( '' === $directory ) {
+ return __( 'Unknown loader', 'tenup-framework' );
+ }
+
+ $roots = [];
+ if ( defined( 'WP_PLUGIN_DIR' ) ) {
+ $roots[] = [ self::to_string( constant( 'WP_PLUGIN_DIR' ) ), __( 'Plugin', 'tenup-framework' ) ];
+ }
+ if ( defined( 'WPMU_PLUGIN_DIR' ) ) {
+ $roots[] = [ self::to_string( constant( 'WPMU_PLUGIN_DIR' ) ), __( 'Must-use plugin', 'tenup-framework' ) ];
+ }
+ if ( function_exists( 'get_theme_root' ) ) {
+ $roots[] = [ self::to_string( get_theme_root() ), __( 'Theme', 'tenup-framework' ) ];
+ }
+
+ foreach ( $roots as $candidate ) {
+ $root = rtrim( $candidate[0], '/' );
+ $type = $candidate[1];
+ if ( '' !== $root && str_starts_with( $directory, $root . '/' ) ) {
+ $relative = ltrim( substr( $directory, strlen( $root ) ), '/' );
+ $segment = explode( '/', $relative )[0];
+
+ /* translators: 1: owner type (Plugin/Theme), 2: plugin or theme folder name. */
+ return sprintf( __( '%1$s: %2$s', 'tenup-framework' ), $type, $segment );
+ }
+ }
+
+ return $directory;
+ }
+
+ /**
+ * A label describing the framework version that recorded a loader.
+ *
+ * @param array $loader The loader record.
+ *
+ * @return string
+ */
+ protected static function version_label( array $loader ): string {
+ $version = self::to_string( $loader['version'] ?? '' );
+ $reference = self::to_string( $loader['reference'] ?? '' );
+
+ if ( '' === $version ) {
+ $version = __( 'unknown', 'tenup-framework' );
+ }
+
+ if ( '' !== $reference ) {
+ $version .= ' (' . substr( $reference, 0, 8 ) . ')';
+ }
+
+ return $version;
+ }
+
+ /**
+ * Resolve the headline cache state for a loader: a severity, a short badge label, and an
+ * optional explanatory note.
+ *
+ * Severity maps to the badge/notice colour. Note that running uncached is a valid default
+ * (caching is opt-in), so it is surfaced as a warning to be noticeable, not as an error.
+ *
+ * @param array $loader The loader record.
+ *
+ * @return array{severity: string, badge: string, note: string}
+ */
+ protected static function cache_state( array $loader ): array {
+ if ( ! empty( $loader['cache_disabled'] ) ) {
+ return [
+ 'severity' => 'warn',
+ 'badge' => __( 'Caching disabled', 'tenup-framework' ),
+ 'note' => __( 'TENUP_FRAMEWORK_DISABLE_CLASS_CACHE is set, so any shipped cache is ignored and classes are discovered live on every request.', 'tenup-framework' ),
+ ];
+ }
+
+ if ( ! empty( $loader['cache_failed'] ) ) {
+ return [
+ 'severity' => 'error',
+ 'badge' => __( 'Cache failed to load — running live', 'tenup-framework' ),
+ 'note' => __( 'A cache file is present but could not be read (corrupt or truncated), so the framework fell back to a live scan on every request. Rebuild the cache in your pipeline and redeploy.', 'tenup-framework' ),
+ ];
+ }
+
+ if ( empty( $loader['cache_exists'] ) ) {
+ return [
+ 'severity' => 'warn',
+ 'badge' => __( 'Uncached — live discovery', 'tenup-framework' ),
+ 'note' => __( 'No cache file is present, so classes are discovered live on every request. That is the correct default for small projects; for large codebases, build a cache in your pipeline (see Build and Deployment).', 'tenup-framework' ),
+ ];
+ }
+
+ if ( empty( $loader['cache_used'] ) ) {
+ return [
+ 'severity' => 'error',
+ 'badge' => __( 'Cache present but not used', 'tenup-framework' ),
+ 'note' => __( 'A cache file exists but is not being used. This is unexpected — check TENUP_FRAMEWORK_DISABLE_CLASS_CACHE.', 'tenup-framework' ),
+ ];
+ }
+
+ return [
+ 'severity' => 'ok',
+ 'badge' => __( 'Cache in use', 'tenup-framework' ),
+ 'note' => '',
+ ];
+ }
+
+ /**
+ * A short description of the cache file on disk — relative age, size, and the absolute build
+ * time in UTC — or a placeholder when none. Format: "Built ago · ()".
+ *
+ * @param array $loader The loader record.
+ *
+ * @return string
+ */
+ protected static function cache_detail( array $loader ): string {
+ $cache_file = self::to_string( $loader['cache_file'] ?? '' );
+
+ if ( '' === $cache_file || ! file_exists( $cache_file ) ) {
+ return __( 'No cache file on disk.', 'tenup-framework' );
+ }
+
+ $mtime = (int) filemtime( $cache_file );
+ $size = (int) filesize( $cache_file );
+
+ if ( ! $mtime ) {
+ return sprintf(
+ /* translators: %s: file size. */
+ __( 'Built at an unknown time · %s', 'tenup-framework' ),
+ size_format( $size )
+ );
+ }
+
+ return sprintf(
+ /* translators: 1: relative age (e.g. "5 minutes"); 2: file size; 3: absolute build time in UTC. */
+ __( 'Built %1$s ago · %2$s · %3$s', 'tenup-framework' ),
+ human_time_diff( $mtime ),
+ size_format( $size ),
+ gmdate( 'Y-m-d H:i:s', $mtime ) . ' UTC'
+ );
+ }
+
+ /**
+ * Output the page's scoped styles once.
+ *
+ * @return void
+ */
+ protected static function render_styles(): void {
+ echo '';
+ }
+
+ /**
+ * Find files in the cache directory that are not the current cache file — usually stale
+ * leftovers from an older framework version.
+ *
+ * @param string $cache_file The current cache file path.
+ *
+ * @return array The unexpected filenames.
+ */
+ protected static function legacy_files( string $cache_file ): array {
+ if ( '' === $cache_file ) {
+ return [];
+ }
+
+ $dir = dirname( $cache_file );
+ if ( ! is_dir( $dir ) ) {
+ return [];
+ }
+
+ $expected = basename( $cache_file );
+ $found = [];
+
+ foreach ( new \DirectoryIterator( $dir ) as $item ) {
+ if ( $item->isDot() || $item->isDir() ) {
+ continue;
+ }
+ $name = $item->getFilename();
+ if ( $name !== $expected ) {
+ $found[] = $name;
+ }
+ }
+
+ return $found;
+ }
+}
diff --git a/src/Module.php b/src/Module.php
index fc67ebf..a4fc4c7 100644
--- a/src/Module.php
+++ b/src/Module.php
@@ -20,24 +20,18 @@ trait Module {
* Lower number will be initialized first.
*
* @note This has no correlation to the `init` priority. It's just a way to allow certain classes to be initialized before others.
- *
- * @return int The priority of the module.
*/
- public function load_order() {
+ public function load_order(): int {
return 10;
}
/**
* Checks whether the Module should run within the current context.
- *
- * @return bool
*/
- abstract public function can_register();
+ abstract public function can_register(): bool;
/**
* Connects the Module with WordPress using Hooks and/or Filters.
- *
- * @return void
*/
- abstract public function register();
+ abstract public function register(): void;
}
diff --git a/src/ModuleInitialization.php b/src/ModuleInitialization.php
index 9f9a8e3..c218554 100644
--- a/src/ModuleInitialization.php
+++ b/src/ModuleInitialization.php
@@ -9,10 +9,12 @@
namespace TenupFramework;
+use Composer\InstalledVersions;
use ReflectionClass;
use Spatie\StructureDiscoverer\Cache\FileDiscoverCacheDriver;
-use Spatie\StructureDiscoverer\Data\DiscoveredStructure;
use Spatie\StructureDiscoverer\Discover;
+use TenupFramework\Cache\ReadOnlyFileDiscoverCacheDriver;
+use TenupFramework\Debug\LoaderDebug;
/**
* ModuleInitialization class.
@@ -21,19 +23,37 @@
*/
class ModuleInitialization {
+ /**
+ * The directory name, within the discovery directory, that holds the class cache.
+ */
+ public const string CACHE_DIR_NAME = 'class-loader-cache';
+
+ /**
+ * The class cache filename.
+ *
+ * Bumping this value invalidates caches written by older framework versions: the
+ * runtime looks for a filename the previous build never produced, so a stale file
+ * is simply ignored until a fresh build regenerates it. The old file is harmless
+ * cruft that a clean deploy clears.
+ */
+ public const string CACHE_FILENAME = 'class-loader-cache-v2.php';
+
+ /**
+ * The Spatie cache identifier.
+ */
+ public const string CACHE_ID = 'TenupFramework';
+
/**
* The class instance.
*
- * @var null|ModuleInitialization
+ * @var ?\TenupFramework\ModuleInitialization
*/
- private static $instance = null;
+ private static ?\TenupFramework\ModuleInitialization $instance = null;
/**
* Get the instance of the class.
- *
- * @return ModuleInitialization
*/
- public static function instance() {
+ public static function instance(): \TenupFramework\ModuleInitialization {
if ( null === self::$instance ) {
self::$instance = new self();
}
@@ -52,7 +72,16 @@ private function __construct() {
*
* @var array
*/
- protected $classes = [];
+ protected array $classes = [];
+
+ /**
+ * Whether the most recent get_classes() call fell back to a live scan because a shipped
+ * cache file failed to load (corrupt or truncated). Read by record_loader_debug() so the
+ * debug page flags the degraded state instead of reporting the cache as healthy.
+ *
+ * @var bool
+ */
+ protected bool $cache_read_failed = false;
/**
* Get all the TenupFramework plugin classes.
@@ -61,61 +90,240 @@ private function __construct() {
*
* @return array
*/
- public function get_classes( $dir ) {
+ public function get_classes( string $dir ): array {
$this->directory_check( $dir );
+ $class_finder = $this->build_discoverer( $dir );
+
+ // The runtime only ever reads a pre-built cache; it never writes one. Caching is
+ // therefore opt-in: with no cache file present we discover live on every request,
+ // which is the correct default. A cache is produced at build time via the
+ // `tenup-framework-generate-class-cache` command and shipped as a build artefact.
+ //
+ // Define TENUP_FRAMEWORK_DISABLE_CLASS_CACHE to ignore any shipped cache and always
+ // discover live (useful for debugging).
+ if ( ! $this->cache_disabled() ) {
+ $class_finder->withCache(
+ self::CACHE_ID,
+ new ReadOnlyFileDiscoverCacheDriver(
+ $this->get_cache_directory( $dir ),
+ false,
+ self::CACHE_FILENAME
+ )
+ );
+ }
+
+ $this->cache_read_failed = false;
+
+ try {
+ // array_filter is inside the try so that a cache which parses but returns a
+ // non-array (not only a truncated one) also falls back rather than fataling here.
+ return array_filter( $class_finder->get(), fn( mixed $cl ) => is_string( $cl ) );
+ } catch ( \Throwable $e ) {
+ // A shipped cache file that is corrupt or truncated — a partial deploy, an
+ // interrupted build, a half-written rsync — would otherwise fatal on every request
+ // (the cache is executable PHP loaded with `require`). Fall back to a fresh live
+ // discovery so the site keeps working, uncached, until the cache is rebuilt. This
+ // is the same spirit as issue #30: a bad cache must never take the site down.
+ $this->cache_read_failed = true;
+
+ if ( function_exists( 'do_action' ) ) {
+ /**
+ * Fires when a shipped class cache could not be read and the runtime fell back
+ * to a live scan. Lets a project log or alert on a degraded (uncached) deploy;
+ * the loader debug page flags the same state.
+ *
+ * @param string $dir The directory whose cache failed to load.
+ * @param \Throwable $e The error raised while reading the cache.
+ */
+ do_action( 'tenup_framework_cache_load_failed', $dir, $e );
+ }
+
+ return array_filter( $this->build_discoverer( $dir )->get(), fn( mixed $cl ) => is_string( $cl ) );
+ }
+ }
+
+ /**
+ * Generate the class cache for a directory and write it to disk.
+ *
+ * This is the build-time counterpart to get_classes(): it is the only place the
+ * framework writes the cache, and it deliberately makes no WordPress calls so it can
+ * run from a plain CLI script during CI without bootstrapping WordPress. The resulting
+ * file is then deployed as a build artefact and read (never rewritten) at runtime.
+ *
+ * @param string|null $dir The directory to search for classes.
+ *
+ * @return array The discovered class names that were cached.
+ */
+ public function generate_cache( ?string $dir = '' ): array {
+ $this->directory_check( $dir );
+
+ $class_finder = $this->build_discoverer( $dir );
+
+ $class_finder->withCache(
+ self::CACHE_ID,
+ new FileDiscoverCacheDriver(
+ $this->get_cache_directory( $dir ),
+ false,
+ self::CACHE_FILENAME
+ )
+ );
+
+ // cache() forces a fresh discovery and overwrites any existing cache file, so a
+ // regenerate always reflects the current code rather than a previous build.
+ $classes = $class_finder->cache();
+
+ return array_filter( $classes, fn( mixed $cl ) => is_string( $cl ) );
+ }
+
+ /**
+ * Build a discoverer configured the same way for both reading and generating, so the
+ * two paths can never drift apart.
+ *
+ * @param string $dir The directory to search for classes.
+ *
+ * @return Discover
+ */
+ protected function build_discoverer( string $dir ): Discover {
// Get all classes from this directory and its subdirectories.
$class_finder = Discover::in( $dir );
// Only fetch classes.
$class_finder->classes();
- // Disable inheritance chain resolution
+ // Disable inheritance chain resolution.
$class_finder->withoutChains();
- // If we are in production or staging, cache the class loader to improve performance.
- if ( $this->should_use_cache() ) {
- $class_finder->withCache(
- __NAMESPACE__,
- new FileDiscoverCacheDriver( $dir . '/class-loader-cache' )
- );
- }
-
- $classes = array_filter( $class_finder->get(), fn( $cl ) => is_string( $cl ) );
+ return $class_finder;
+ }
- // Return the classes
- return $classes;
+ /**
+ * Get the absolute path to the cache directory for a discovery directory.
+ *
+ * @param string $dir The directory to search for classes.
+ *
+ * @return string
+ */
+ protected function get_cache_directory( string $dir ): string {
+ return rtrim( $dir, '/' ) . '/' . self::CACHE_DIR_NAME;
}
/**
- * Should we set up and use the class cache?
+ * Whether class caching has been explicitly disabled.
+ *
+ * When true, the runtime ignores any shipped cache and discovers classes live on every
+ * request. Useful for debugging a suspected stale or incorrect cache.
*
* @return bool
*/
- protected function should_use_cache(): bool {
- if ( defined( 'VIP_GO_APP_ENVIRONMENT' ) ) {
- return false;
+ protected function cache_disabled(): bool {
+ return defined( 'TENUP_FRAMEWORK_DISABLE_CLASS_CACHE' ) && true === TENUP_FRAMEWORK_DISABLE_CLASS_CACHE;
+ }
+
+ /**
+ * Discover the classes in a directory live, ignoring any cache.
+ *
+ * Used by the admin-only debug page's on-demand staleness check to compare what is actually
+ * on disk against what the cache loaded.
+ *
+ * @param string|null $dir The directory to search for classes.
+ *
+ * @return array
+ */
+ public function discover_live( ?string $dir ): array {
+ $this->directory_check( $dir );
+
+ return array_values( array_filter( $this->build_discoverer( $dir )->get(), fn( mixed $cl ) => is_string( $cl ) ) );
+ }
+
+ /**
+ * Hand loader metadata to the admin-only debug tooling.
+ *
+ * Front-end requests do nothing here: the data is only viewable in the admin, so it is only
+ * gathered there. The is_admin() check happens before LoaderDebug is referenced, so that
+ * class never autoloads on the front end.
+ *
+ * @param string $dir The directory that was discovered.
+ * @param array $classes The discovered class names.
+ * @param float $discovery_seconds Seconds spent obtaining the class list (cache read or live scan).
+ * @param float $lookup_seconds Seconds spent reflecting, instantiating and registering the classes.
+ *
+ * @return void
+ */
+ protected function record_loader_debug( string $dir, array $classes, float $discovery_seconds = 0.0, float $lookup_seconds = 0.0 ): void {
+ if ( ! function_exists( 'is_admin' ) || ! is_admin() ) {
+ return;
}
- if ( ! in_array( wp_get_environment_type(), [ 'production', 'staging' ], true ) ) {
- return false;
+ // is_admin() is also true for admin-ajax.php. The debug page is a normal admin GET that
+ // re-runs discovery and records afresh, so recording on ajax requests is pure waste
+ // (often triggered from the front end). Skip them.
+ if ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) {
+ return;
}
- if ( defined( 'TENUP_FRAMEWORK_DISABLE_CLASS_CACHE' ) && true === TENUP_FRAMEWORK_DISABLE_CLASS_CACHE ) {
- return false;
+ $cache_file = $this->get_cache_directory( $dir ) . '/' . self::CACHE_FILENAME;
+ $cache_exists = file_exists( $cache_file );
+ $disabled = $this->cache_disabled();
+ $failed = $this->cache_read_failed;
+
+ LoaderDebug::record(
+ [
+ 'directory' => $dir,
+ 'cache_file' => $cache_file,
+ 'cache_exists' => $cache_exists,
+ // A present cache that failed to load was not actually used — the runtime fell
+ // back to a live scan — so report it as such rather than "in use".
+ 'cache_used' => $cache_exists && ! $disabled && ! $failed,
+ 'cache_disabled' => $disabled,
+ 'cache_failed' => $failed,
+ 'classes' => $classes,
+ 'version' => $this->framework_version(),
+ 'reference' => $this->framework_reference(),
+ 'discovery_seconds' => $discovery_seconds,
+ 'lookup_seconds' => $lookup_seconds,
+ ]
+ );
+ }
+
+ /**
+ * The installed framework version, or an empty string when it cannot be determined.
+ *
+ * @return string
+ */
+ protected function framework_version(): string {
+ if ( class_exists( InstalledVersions::class ) && InstalledVersions::isInstalled( '10up/wp-framework' ) ) {
+ return (string) InstalledVersions::getPrettyVersion( '10up/wp-framework' );
}
- return true;
+ return '';
+ }
+
+ /**
+ * The installed framework reference (git hash), or an empty string when unavailable.
+ *
+ * @return string
+ */
+ protected function framework_reference(): string {
+ if ( class_exists( InstalledVersions::class ) && InstalledVersions::isInstalled( '10up/wp-framework' ) ) {
+ return (string) InstalledVersions::getReference( '10up/wp-framework' );
+ }
+
+ return '';
}
/**
* Check if the directory exists.
*
- * @param string $dir The directory to check.
+ * Returning at all proves $dir is a usable path: every other outcome throws. The assert
+ * hands that guarantee to PHPStan so callers taking a nullable $dir do not each need their
+ * own redundant null check.
*
- * @throws \RuntimeException If the directory does not exist.
+ * @param string|null $dir The directory to check.
*
- * @return bool
+ * @phpstan-assert non-empty-string $dir
+ *
+ * @throws \RuntimeException If the directory does not exist.
*/
- protected function directory_check( $dir ): bool {
+ protected function directory_check( ?string $dir ): bool {
if ( empty( $dir ) ) {
throw new \RuntimeException( 'Directory is required to initialize classes.' );
}
@@ -132,14 +340,22 @@ protected function directory_check( $dir ): bool {
* Initialize all the TenupFramework plugin classes.
*
* @param string $dir The directory to search for classes.
- *
- * @return void
*/
- public function init_classes( $dir = '' ) {
+ public function init_classes( ?string $dir = '' ): void {
$this->directory_check( $dir );
+ // Time discovery (a cache read when a cache is present, a live filesystem scan
+ // otherwise) separately from the reflection/instantiation work below, so the debug
+ // page can show where the request's time actually goes. hrtime() is monotonic, so an
+ // NTP adjustment mid-request cannot produce a negative or wildly wrong delta.
+ $discovery_start = hrtime( true );
+ $classes = $this->get_classes( $dir );
+ $discovery_seconds = ( hrtime( true ) - $discovery_start ) / 1e9;
+
+ $lookup_start = hrtime( true );
+
$load_class_order = [];
- foreach ( $this->get_classes( $dir ) as $class ) {
+ foreach ( $classes as $class ) {
// Create a slug for the class name.
$slug = $this->slugify_class_name( $class );
@@ -161,7 +377,7 @@ public function init_classes( $dir = '' ) {
}
// Check if the class implements ModuleInterface before instantiating it
- if ( ! $reflection_class->implementsInterface( 'TenupFramework\ModuleInterface' ) ) {
+ if ( ! $reflection_class->implementsInterface( \TenupFramework\ModuleInterface::class ) ) {
continue;
}
@@ -197,6 +413,10 @@ public function init_classes( $dir = '' ) {
}
}
}
+
+ $lookup_seconds = ( hrtime( true ) - $lookup_start ) / 1e9;
+
+ $this->record_loader_debug( $dir, $classes, $discovery_seconds, $lookup_seconds );
}
/**
@@ -204,8 +424,6 @@ public function init_classes( $dir = '' ) {
*
* @param string $class_name The name of the class to load.
*
- * @return false|ReflectionClass Returns a ReflectionClass instance if the class is loadable, or false if it is not.
- *
* @phpstan-ignore missingType.generics
*/
public function get_fully_loadable_class( string $class_name ): false|ReflectionClass {
@@ -213,7 +431,7 @@ public function get_fully_loadable_class( string $class_name ): false|Reflection
// Create a new reflection of the class.
// @phpstan-ignore argument.type
return new ReflectionClass( $class_name );
- } catch ( \Throwable $e ) {
+ } catch ( \Throwable ) {
// This includes ReflectionException, Error due to missing parent, etc.
return false;
}
@@ -223,10 +441,8 @@ public function get_fully_loadable_class( string $class_name ): false|Reflection
* Slugify a class name.
*
* @param string $class_name The class name.
- *
- * @return string
*/
- protected function slugify_class_name( $class_name ) {
+ protected function slugify_class_name( string $class_name ): string {
return sanitize_title( str_replace( '\\', '-', $class_name ) );
}
@@ -234,10 +450,8 @@ protected function slugify_class_name( $class_name ) {
* Get a class by its full class name, including namespace.
*
* @param string $class_name The class name & namespace.
- *
- * @return false|ModuleInterface
*/
- public function get_class( $class_name ) {
+ public function get_class( string $class_name ): false|ModuleInterface {
$class_name = $this->slugify_class_name( $class_name );
if ( isset( $this->classes[ $class_name ] ) ) {
@@ -252,7 +466,7 @@ public function get_class( $class_name ) {
*
* @return array
*/
- public function get_all_classes() {
+ public function get_all_classes(): array {
return $this->classes;
}
@@ -260,10 +474,8 @@ public function get_all_classes() {
* Get an initialized class by its full class name, including namespace.
*
* @param string $class_name The class name including the namespace.
- *
- * @return false|ModuleInterface
*/
- public static function get_module( $class_name ) {
+ public static function get_module( string $class_name ): false|ModuleInterface {
return self::instance()->get_class( $class_name );
}
}
diff --git a/src/ModuleInterface.php b/src/ModuleInterface.php
index 960ec7b..b03b7e1 100644
--- a/src/ModuleInterface.php
+++ b/src/ModuleInterface.php
@@ -20,22 +20,16 @@ interface ModuleInterface {
* Lower number will be initialized first.
*
* @note This has no correlation to the `init` priority. It's just a way to allow certain classes to be initialized before others.
- *
- * @return int The priority of the module.
*/
- public function load_order();
+ public function load_order(): int;
/**
* Checks whether the Module should run within the current context.
- *
- * @return bool
*/
- public function can_register();
+ public function can_register(): bool;
/**
* Connects the Module with WordPress using Hooks and/or Filters.
- *
- * @return void
*/
- public function register();
+ public function register(): void;
}
diff --git a/src/PostTypes/AbstractCorePostType.php b/src/PostTypes/AbstractCorePostType.php
index 1823423..4a92884 100644
--- a/src/PostTypes/AbstractCorePostType.php
+++ b/src/PostTypes/AbstractCorePostType.php
@@ -22,10 +22,8 @@ abstract class AbstractCorePostType extends AbstractPostType {
* Get the singular post type label.
*
* No-op for core post types since they are already registered by WordPress.
- *
- * @return string
*/
- public function get_singular_label() {
+ public function get_singular_label(): string {
return '';
}
@@ -33,10 +31,8 @@ public function get_singular_label() {
* Get the plural post type label.
*
* No-op for core post types since they are already registered by WordPress.
- *
- * @return string
*/
- public function get_plural_label() {
+ public function get_plural_label(): string {
return '';
}
@@ -44,10 +40,8 @@ public function get_plural_label() {
* Get the menu icon for the post type.
*
* No-op for core post types since they are already registered by WordPress.
- *
- * @return string
*/
- public function get_menu_icon() {
+ public function get_menu_icon(): string {
return '';
}
@@ -55,23 +49,16 @@ public function get_menu_icon() {
* Checks whether the Module should run within the current context.
*
* True for core post types since they are already registered by WordPress.
- *
- * @return bool
*/
- public function can_register() {
+ public function can_register(): bool {
return true;
}
/**
* Registers a post type and associates its taxonomies.
- *
- * @uses $this->get_name() to get the post's type name.
- * @return Bool Whether this theme has supports for this post type.
*/
- public function register() {
+ public function register(): void {
$this->register_taxonomies();
$this->after_register();
-
- return true;
}
}
diff --git a/src/PostTypes/AbstractPostType.php b/src/PostTypes/AbstractPostType.php
index d945df3..dfcc58a 100644
--- a/src/PostTypes/AbstractPostType.php
+++ b/src/PostTypes/AbstractPostType.php
@@ -47,23 +47,23 @@ abstract class AbstractPostType implements ModuleInterface {
/**
* Get the post type name.
*
- * @return string
+ * WordPress requires a post type key to be a non-empty lowercase string, so the contract
+ * states that rather than a bare string. Returning anything else is rejected by
+ * register_post_type() at runtime.
+ *
+ * @return lowercase-string&non-empty-string
*/
- abstract public function get_name();
+ abstract public function get_name(): string;
/**
* Get the singular post type label.
- *
- * @return string
*/
- abstract public function get_singular_label();
+ abstract public function get_singular_label(): string;
/**
* Get the plural post type label.
- *
- * @return string
*/
- abstract public function get_plural_label();
+ abstract public function get_plural_label(): string;
/**
* Get the menu icon for the post type.
@@ -71,26 +71,20 @@ abstract public function get_plural_label();
* This can be a base64 encoded SVG, a dashicons class or 'none' to leave it empty so it can be filled with CSS.
*
* @see https://developer.wordpress.org/resource/dashicons/
- *
- * @return string
*/
- abstract public function get_menu_icon();
+ abstract public function get_menu_icon(): string;
/**
* Get the menu position for the post type.
- *
- * @return int|null
*/
- public function get_menu_position() {
+ public function get_menu_position(): int|null {
return null;
}
/**
* Is the post type hierarchical?
- *
- * @return bool
*/
- public function is_hierarchical() {
+ public function is_hierarchical(): bool {
return false;
}
@@ -99,7 +93,7 @@ public function is_hierarchical() {
*
* @return array
*/
- public function get_editor_supports() {
+ public function get_editor_supports(): array {
$supports = [
'title',
'editor',
@@ -154,7 +148,7 @@ public function get_editor_supports() {
* template_lock?: string|false,
* }
*/
- public function get_options() {
+ public function get_options(): array {
$options = [
'labels' => $this->get_labels(),
'public' => true,
@@ -182,7 +176,7 @@ public function get_options() {
*
* @return array
*/
- public function get_labels() {
+ public function get_labels(): array {
$plural_label = $this->get_plural_label();
$singular_label = $this->get_singular_label();
@@ -225,25 +219,18 @@ public function get_labels() {
/**
* Registers a post type and associates its taxonomies.
- *
- * @uses $this->get_name() to get the post's type name.
- * @return Bool Whether this theme has supports for this post type.
*/
- public function register() {
+ public function register(): void {
$this->register_post_type();
$this->register_taxonomies();
$this->after_register();
-
- return true;
}
/**
* Registers the current post type with WordPress.
- *
- * @return void
*/
- public function register_post_type() {
+ public function register_post_type(): void {
register_post_type(
$this->get_name(),
$this->get_options()
@@ -252,10 +239,8 @@ public function register_post_type() {
/**
* Registers the taxonomies declared with the current post type.
- *
- * @return void
*/
- public function register_taxonomies() {
+ public function register_taxonomies(): void {
$taxonomies = $this->get_supported_taxonomies();
$object_type = $this->get_name();
@@ -276,16 +261,14 @@ public function register_taxonomies() {
*
* @return array
*/
- public function get_supported_taxonomies() {
+ public function get_supported_taxonomies(): array {
return [];
}
/**
* Run any code after the post type has been registered.
- *
- * @return void
*/
- public function after_register() {
+ public function after_register(): void {
// Do nothing.
}
}
diff --git a/src/Taxonomies/AbstractTaxonomy.php b/src/Taxonomies/AbstractTaxonomy.php
index c3dfdf3..ccb0ffe 100644
--- a/src/Taxonomies/AbstractTaxonomy.php
+++ b/src/Taxonomies/AbstractTaxonomy.php
@@ -44,40 +44,36 @@ abstract class AbstractTaxonomy implements ModuleInterface {
* Used to alter the order in which classes are initialized.
*
* Lower number will be initialized first.
- *
- * @return int
*/
- public function load_order() {
+ public function load_order(): int {
return 9;
}
/**
* Get the taxonomy name.
*
- * @return string
+ * WordPress requires a taxonomy key to be a non-empty lowercase string, so the contract
+ * states that rather than a bare string. Returning anything else is rejected by
+ * register_taxonomy() at runtime.
+ *
+ * @return lowercase-string&non-empty-string
*/
- abstract public function get_name();
+ abstract public function get_name(): string;
/**
* Get the singular taxonomy label.
- *
- * @return string
*/
- abstract public function get_singular_label();
+ abstract public function get_singular_label(): string;
/**
* Get the plural taxonomy label.
- *
- * @return string
*/
- abstract public function get_plural_label();
+ abstract public function get_plural_label(): string;
/**
* Is the taxonomy hierarchical?
- *
- * @return bool
*/
- public function is_hierarchical() {
+ public function is_hierarchical(): bool {
return false;
}
@@ -85,9 +81,8 @@ public function is_hierarchical() {
* Register hooks and actions.
*
* @uses $this->get_name() to get the taxonomy's slug.
- * @return bool
*/
- public function register() {
+ public function register(): void {
\register_taxonomy(
$this->get_name(),
$this->get_post_types(),
@@ -95,8 +90,6 @@ public function register() {
);
$this->after_register();
-
- return true;
}
/**
@@ -137,7 +130,7 @@ public function register() {
* _builtin?: bool,
* }
*/
- public function get_options() {
+ public function get_options(): array {
return [
'labels' => $this->get_labels(),
'hierarchical' => $this->is_hierarchical(),
@@ -154,7 +147,7 @@ public function get_options() {
*
* @return array
*/
- public function get_labels() {
+ public function get_labels(): array {
$plural_label = $this->get_plural_label();
$singular_label = $this->get_singular_label();
@@ -187,16 +180,14 @@ public function get_labels() {
*
* @return array
*/
- public function get_post_types() {
+ public function get_post_types(): array {
return [];
}
/**
* Run any code after the taxonomy has been registered.
- *
- * @return void
*/
- public function after_register() {
+ public function after_register(): void {
// Do nothing.
}
}
diff --git a/tests/Assets/GetAssetInfoTest.php b/tests/Assets/GetAssetInfoTest.php
index d08139f..415772f 100644
--- a/tests/Assets/GetAssetInfoTest.php
+++ b/tests/Assets/GetAssetInfoTest.php
@@ -130,4 +130,117 @@ public function test_get_asset_info_throws_exception_when_called_without_setting
slug: 'test-script'
);
}
+
+ /**
+ * Test get_asset_info with prefix-based slug handling (css/, js/, blocks/).
+ *
+ * @return void
+ */
+ public function test_get_asset_info_with_prefix_based_slug() {
+ $asset_info = new class() {
+ use GetAssetInfo;
+ };
+
+ $asset_info->setup_asset_vars(
+ dist_path: dirname( __DIR__, 2 ) . '/fixtures/assets/dist',
+ fallback_version: '1.0.0'
+ );
+
+ // Test CSS prefix with existing fixture
+ $asset = $asset_info->get_asset_info( slug: 'css/test-style' );
+ $this->assertIsArray( $asset );
+ $this->assertArrayHasKey( 'version', $asset );
+ $this->assertArrayHasKey( 'dependencies', $asset );
+ $vars = require dirname( __DIR__, 2 ) . '/fixtures/assets/dist/css/test-style.asset.php';
+ $this->assertEquals( $vars, $asset );
+
+ // Test JS prefix with existing fixture
+ $asset = $asset_info->get_asset_info( slug: 'js/test-script' );
+ $this->assertIsArray( $asset );
+ $this->assertArrayHasKey( 'version', $asset );
+ $this->assertArrayHasKey( 'dependencies', $asset );
+ $vars = require dirname( __DIR__, 2 ) . '/fixtures/assets/dist/js/test-script.asset.php';
+ $this->assertEquals( $vars, $asset );
+
+ // Test blocks prefix with existing fixture
+ $asset = $asset_info->get_asset_info( slug: 'blocks/test-block' );
+ $this->assertIsArray( $asset );
+ $this->assertArrayHasKey( 'version', $asset );
+ $this->assertArrayHasKey( 'dependencies', $asset );
+ $vars = require dirname( __DIR__, 2 ) . '/fixtures/assets/dist/blocks/test-block.asset.php';
+ $this->assertEquals( $vars, $asset );
+ }
+
+ /**
+ * Test get_asset_info priority order: prefix-based slugs take priority over fallback.
+ *
+ * @return void
+ */
+ public function test_get_asset_info_priority_order_prefix_vs_fallback() {
+ $asset_info = new class() {
+ use GetAssetInfo;
+ };
+
+ $asset_info->setup_asset_vars(
+ dist_path: dirname( __DIR__, 2 ) . '/fixtures/assets/dist',
+ fallback_version: '1.0.0'
+ );
+
+ // Test that prefix-based slug works with existing fixtures
+ $asset = $asset_info->get_asset_info( slug: 'css/test-style' );
+ $this->assertIsArray( $asset );
+ $this->assertArrayHasKey( 'version', $asset );
+ $this->assertArrayHasKey( 'dependencies', $asset );
+ $vars = require dirname( __DIR__, 2 ) . '/fixtures/assets/dist/css/test-style.asset.php';
+ $this->assertEquals( $vars, $asset );
+
+ // Test that fallback still works for non-prefixed slugs
+ $asset = $asset_info->get_asset_info( slug: 'test-script' );
+ $this->assertIsArray( $asset );
+ $this->assertArrayHasKey( 'version', $asset );
+ $this->assertArrayHasKey( 'dependencies', $asset );
+ $vars = require dirname( __DIR__, 2 ) . '/fixtures/assets/dist/js/test-script.asset.php';
+ $this->assertEquals( $vars, $asset );
+ }
+
+ /**
+ * Test get_asset_info fallback behavior when direct file doesn't exist.
+ *
+ * @return void
+ */
+ public function test_get_asset_info_fallback_when_direct_file_missing() {
+ $asset_info = new class() {
+ use GetAssetInfo;
+ };
+
+ $asset_info->setup_asset_vars(
+ dist_path: dirname( __DIR__, 2 ) . '/fixtures/assets/dist',
+ fallback_version: '1.0.0'
+ );
+
+ // Test that it falls back to JS directory first (priority order: js -> css -> blocks)
+ // Using existing fixture that exists in js/ directory
+ $asset = $asset_info->get_asset_info( slug: 'test-script' );
+ $this->assertIsArray( $asset );
+ $this->assertArrayHasKey( 'version', $asset );
+ $this->assertArrayHasKey( 'dependencies', $asset );
+ $vars = require dirname( __DIR__, 2 ) . '/fixtures/assets/dist/js/test-script.asset.php';
+ $this->assertEquals( $vars, $asset );
+
+ // Test CSS fallback with existing fixture
+ $asset = $asset_info->get_asset_info( slug: 'test-style' );
+ $this->assertIsArray( $asset );
+ $this->assertArrayHasKey( 'version', $asset );
+ $this->assertArrayHasKey( 'dependencies', $asset );
+ $vars = require dirname( __DIR__, 2 ) . '/fixtures/assets/dist/css/test-style.asset.php';
+ $this->assertEquals( $vars, $asset );
+
+ // Test blocks fallback with existing fixture
+ $asset = $asset_info->get_asset_info( slug: 'test-block' );
+ $this->assertIsArray( $asset );
+ $this->assertArrayHasKey( 'version', $asset );
+ $this->assertArrayHasKey( 'dependencies', $asset );
+ $vars = require dirname( __DIR__, 2 ) . '/fixtures/assets/dist/blocks/test-block.asset.php';
+ $this->assertEquals( $vars, $asset );
+ }
}
diff --git a/tests/Bin/GenerateClassCacheTest.php b/tests/Bin/GenerateClassCacheTest.php
new file mode 100644
index 0000000..81d10db
--- /dev/null
+++ b/tests/Bin/GenerateClassCacheTest.php
@@ -0,0 +1,232 @@
+
+ */
+ private $temp_dirs = [];
+
+ /**
+ * Remove any temporary directories created during the test.
+ *
+ * @return void
+ */
+ protected function tearDown(): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
+ foreach ( $this->temp_dirs as $dir ) {
+ $this->remove_dir( $dir );
+ }
+ $this->temp_dirs = [];
+
+ parent::tearDown();
+ }
+
+ /**
+ * Running the command against a directory writes a readable cache of its classes.
+ *
+ * @return void
+ */
+ public function test_generates_a_cache_for_a_directory() {
+ $dir = $this->example_copy( 'plugin-inc' );
+
+ $result = $this->run_bin( [ $dir ] );
+
+ $this->assertSame( 0, $result['exit'], $result['stderr'] );
+ $this->assertStringContainsString( 'Cached', $result['stdout'] );
+
+ $cache_file = $this->cache_file_path( $dir );
+ $this->assertFileExists( $cache_file );
+
+ $cached = require $cache_file;
+ $this->assertContains( 'TenupFrameworkExamples\\Modules\\GreetingModule', $cached );
+ $this->assertContains( 'TenupFrameworkExamples\\Support\\Formatter', $cached );
+ }
+
+ /**
+ * With no arguments the command prints usage to stderr and exits non-zero.
+ *
+ * @return void
+ */
+ public function test_reports_usage_and_fails_without_arguments() {
+ $result = $this->run_bin( [] );
+
+ $this->assertSame( 1, $result['exit'] );
+ $this->assertStringContainsString( 'Usage:', $result['stderr'] );
+ }
+
+ /**
+ * A missing directory fails that directory (non-zero exit, error on stderr) but the command
+ * still processes the directories that are valid.
+ *
+ * @return void
+ */
+ public function test_missing_directory_fails_but_valid_directories_still_cache() {
+ $good = $this->example_copy( 'plugin-inc' );
+ $missing = sys_get_temp_dir() . '/tenup_bin_missing_' . uniqid( '', true );
+
+ $result = $this->run_bin( [ $missing, $good ] );
+
+ $this->assertSame( 1, $result['exit'] );
+ $this->assertStringContainsString( $missing, $result['stderr'] );
+ $this->assertStringContainsString( 'Failed to generate cache', $result['stderr'] );
+
+ // The valid directory was still cached despite the earlier failure.
+ $this->assertFileExists( $this->cache_file_path( $good ) );
+ }
+
+ /**
+ * Several directories can be cached in a single invocation.
+ *
+ * @return void
+ */
+ public function test_caches_multiple_directories() {
+ $first = $this->example_copy( 'plugin-inc' );
+ $second = $this->example_copy( 'second-inc' );
+
+ $result = $this->run_bin( [ $first, $second ] );
+
+ $this->assertSame( 0, $result['exit'], $result['stderr'] );
+ $this->assertFileExists( $this->cache_file_path( $first ) );
+ $this->assertFileExists( $this->cache_file_path( $second ) );
+
+ $cached = require $this->cache_file_path( $second );
+ $this->assertContains( 'TenupFrameworkExamples\\Widgets\\Card', $cached );
+ }
+
+ /**
+ * Run the bin script with the given arguments, returning its stdout, stderr and exit code.
+ *
+ * @param array $args The arguments to pass after the script name.
+ *
+ * @return array{stdout: string, stderr: string, exit: int}
+ */
+ private function run_bin( array $args ): array {
+ $script = dirname( __DIR__, 2 ) . '/bin/tenup-framework-generate-class-cache';
+ $command = array_map( 'escapeshellarg', array_merge( [ PHP_BINARY, $script ], $args ) );
+
+ $descriptors = [
+ 1 => [ 'pipe', 'w' ],
+ 2 => [ 'pipe', 'w' ],
+ ];
+
+ $process = proc_open( implode( ' ', $command ), $descriptors, $pipes );
+ $this->assertIsResource( $process );
+
+ $stdout = (string) stream_get_contents( $pipes[1] );
+ $stderr = (string) stream_get_contents( $pipes[2] );
+ fclose( $pipes[1] );
+ fclose( $pipes[2] );
+
+ $exit = proc_close( $process );
+
+ return [
+ 'stdout' => $stdout,
+ 'stderr' => $stderr,
+ 'exit' => $exit,
+ ];
+ }
+
+ /**
+ * Copy an example directory into a fresh temp directory so the command can write a cache
+ * into it without touching the committed examples.
+ *
+ * @param string $name The example directory name under tests/examples.
+ *
+ * @return string The path to the temp copy.
+ */
+ private function example_copy( string $name ): string {
+ $source = __DIR__ . '/../examples/' . $name;
+ $target = sys_get_temp_dir() . '/tenup_bin_' . $name . '_' . uniqid( '', true );
+
+ $this->copy_dir( $source, $target );
+ $this->temp_dirs[] = $target;
+
+ return $target;
+ }
+
+ /**
+ * The absolute path to the cache file the command writes for a directory.
+ *
+ * @param string $dir The discovery directory.
+ *
+ * @return string
+ */
+ private function cache_file_path( string $dir ): string {
+ return $dir . '/' . ModuleInitialization::CACHE_DIR_NAME . '/' . ModuleInitialization::CACHE_FILENAME;
+ }
+
+ /**
+ * Recursively copy a directory.
+ *
+ * @param string $source The source directory.
+ * @param string $target The target directory.
+ *
+ * @return void
+ */
+ private function copy_dir( string $source, string $target ): void {
+ mkdir( $target, 0777, true );
+
+ $items = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator( $source, \FilesystemIterator::SKIP_DOTS ),
+ \RecursiveIteratorIterator::SELF_FIRST
+ );
+
+ foreach ( $items as $item ) {
+ $destination = $target . '/' . $items->getSubPathname();
+ if ( $item->isDir() ) {
+ mkdir( $destination, 0777, true );
+ } else {
+ copy( $item->getPathname(), $destination );
+ }
+ }
+ }
+
+ /**
+ * Recursively remove a directory.
+ *
+ * @param string $dir The directory to remove.
+ *
+ * @return void
+ */
+ private function remove_dir( string $dir ): void {
+ if ( ! is_dir( $dir ) ) {
+ return;
+ }
+
+ $items = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ),
+ \RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ( $items as $item ) {
+ if ( $item->isDir() ) {
+ rmdir( $item->getPathname() );
+ } else {
+ unlink( $item->getPathname() );
+ }
+ }
+
+ rmdir( $dir );
+ }
+}
diff --git a/tests/BlockRegistrarTest.php b/tests/BlockRegistrarTest.php
new file mode 100644
index 0000000..80cd0a0
--- /dev/null
+++ b/tests/BlockRegistrarTest.php
@@ -0,0 +1,346 @@
+assertInstanceOf( \TenupFramework\ModuleInterface::class, $block_registrar );
+ $this->assertInstanceOf( \TenupFramework\BlockRegistrar::class, $block_registrar );
+ }
+
+ /**
+ * Test that can_register returns true by default.
+ *
+ * @return void
+ */
+ public function test_can_register_returns_true() {
+ $block_registrar = new TestBlockRegistrar();
+
+ $this->assertTrue( $block_registrar->can_register() );
+ }
+
+ /**
+ * Test that get_blocks_directory is abstract and must be implemented.
+ *
+ * @return void
+ */
+ public function test_get_blocks_directory_is_abstract() {
+ $this->expectException( \Error::class );
+ new \TenupFramework\BlockRegistrar();
+ }
+
+ /**
+ * Test that register method calls parent register and adds hooks.
+ *
+ * @return void
+ */
+ public function test_register_method_adds_hooks() {
+ // Create a concrete test class
+ $block_registrar = new TestBlockRegistrar();
+
+ // This should not throw an exception
+ $block_registrar->register();
+ $this->assertTrue( true ); // If we get here, register() worked
+ }
+
+ /**
+ * Test that get_blocks_directory returns an array.
+ *
+ * @return void
+ */
+ public function test_get_blocks_directory_returns_array() {
+ $block_registrar = new TestBlockRegistrar();
+ $directories = $block_registrar->get_blocks_directory();
+
+ $this->assertIsArray( $directories );
+ $this->assertCount( 1, $directories );
+ $this->assertEquals( '/test/blocks/', $directories[0] );
+ }
+
+ /**
+ * Test that multiple directories can be returned.
+ *
+ * @return void
+ */
+ public function test_multiple_directories_support() {
+ $block_registrar = new TestMultiDirectoryBlockRegistrar();
+ $directories = $block_registrar->get_blocks_directory();
+
+ $this->assertIsArray( $directories );
+ $this->assertCount( 3, $directories );
+ $this->assertEquals( '/test/blocks/', $directories[0] );
+ $this->assertEquals( '/test/custom-blocks/', $directories[1] );
+ $this->assertEquals( '/test/vendor-blocks/', $directories[2] );
+ }
+
+ /**
+ * Test that empty directory array is handled correctly.
+ *
+ * @return void
+ */
+ public function test_empty_directory_array_support() {
+ $block_registrar = new TestEmptyDirectoryBlockRegistrar();
+ $directories = $block_registrar->get_blocks_directory();
+
+ $this->assertIsArray( $directories );
+ $this->assertEmpty( $directories );
+ }
+
+ /**
+ * Test register_blocks with non-existent directories.
+ *
+ * @return void
+ */
+ public function test_register_blocks_with_non_existent_directories() {
+ $block_registrar = new TestBlockRegistrar();
+
+ // This test verifies the method exists and can be called
+ // In a real WordPress environment, it would handle non-existent directories gracefully
+ $this->assertTrue( method_exists( $block_registrar, 'register_blocks' ) );
+ }
+
+ /**
+ * Test register_blocks with empty directory array.
+ *
+ * @return void
+ */
+ public function test_register_blocks_with_empty_directory_array() {
+ $block_registrar = new TestEmptyDirectoryBlockRegistrar();
+
+ // This test verifies the method exists and can be called
+ // In a real WordPress environment, it would handle empty directories gracefully
+ $this->assertTrue( method_exists( $block_registrar, 'register_blocks' ) );
+ }
+
+ /**
+ * Test get_block_options without markup.php file.
+ *
+ * @return void
+ */
+ public function test_get_block_options_without_markup() {
+ $block_registrar = new TestBlockRegistrar();
+
+ // Use reflection to access protected method
+ $reflection = new \ReflectionClass( $block_registrar );
+ $method = $reflection->getMethod( 'get_block_options' );
+ $method->setAccessible( true );
+
+ $options = $method->invoke( $block_registrar, '/test/block-without-markup/' );
+
+ $this->assertIsArray( $options );
+ $this->assertEmpty( $options );
+ }
+
+ /**
+ * Test get_block_options with markup.php file.
+ *
+ * @return void
+ */
+ public function test_get_block_options_with_markup() {
+ $block_registrar = new TestBlockRegistrar();
+
+ // Use reflection to access protected method
+ $reflection = new \ReflectionClass( $block_registrar );
+ $method = $reflection->getMethod( 'get_block_options' );
+ $method->setAccessible( true );
+
+ // Mock file_exists to return true for markup.php
+ $original_file_exists = 'file_exists';
+ if ( function_exists( 'file_exists' ) ) {
+ // In a real test environment, you'd mock this properly
+ // For now, we'll test the structure
+ $options = $method->invoke( $block_registrar, '/test/block-with-markup/' );
+
+ // The method should return an array (empty if file doesn't exist)
+ $this->assertIsArray( $options );
+ }
+ }
+
+ /**
+ * Test register_allowed_block_types method.
+ *
+ * @return void
+ */
+ public function test_register_allowed_block_types() {
+ $block_registrar = new TestBlockRegistrar();
+
+ // Use reflection to access protected method
+ $reflection = new \ReflectionClass( $block_registrar );
+ $method = $reflection->getMethod( 'register_allowed_block_types' );
+ $method->setAccessible( true );
+
+ $block_names = [ 'test/block1', 'test/block2' ];
+
+ // This should not throw an exception
+ $method->invoke( $block_registrar, $block_names );
+ $this->assertTrue( true ); // If we get here, no exception was thrown
+ }
+
+ /**
+ * Test WordPress hook registration.
+ *
+ * @return void
+ */
+ public function test_wordpress_hook_registration() {
+ $block_registrar = new TestBlockRegistrar();
+
+ // This should not throw an exception when registering hooks
+ $block_registrar->register();
+ $this->assertTrue( true ); // If we get here, register() worked without throwing
+ }
+
+ /**
+ * Test that multiple BlockRegistrar instances don't conflict.
+ *
+ * @return void
+ */
+ public function test_multiple_instances_no_conflict() {
+ // Create two different instances
+ $theme_blocks = new TestBlockRegistrar();
+ $plugin_blocks = new TestMultiDirectoryBlockRegistrar();
+
+ // Test that both instances can be created without conflicts
+ $this->assertInstanceOf( \TenupFramework\BlockRegistrar::class, $theme_blocks );
+ $this->assertInstanceOf( \TenupFramework\BlockRegistrar::class, $plugin_blocks );
+ $this->assertNotSame( $theme_blocks, $plugin_blocks );
+ }
+
+ /**
+ * Test static block name tracking.
+ *
+ * @return void
+ */
+ public function test_static_block_name_tracking() {
+ // Use reflection to access static properties
+ $reflection = new \ReflectionClass( \TenupFramework\BlockRegistrar::class );
+
+ // Test that static properties exist
+ $this->assertTrue( $reflection->hasProperty( 'registered_block_names' ) );
+ $this->assertTrue( $reflection->hasProperty( 'filter_registered' ) );
+ $this->assertTrue( $reflection->hasProperty( 'block_sources' ) );
+
+ // Test that the static filter method exists
+ $this->assertTrue( $reflection->hasMethod( 'filter_allowed_block_types' ) );
+ }
+
+ /**
+ * Test block conflict detection methods.
+ *
+ * @return void
+ */
+ public function test_block_conflict_detection() {
+ // Test conflict detection methods exist
+ $this->assertTrue( method_exists( \TenupFramework\BlockRegistrar::class, 'has_block_conflict' ) );
+ $this->assertTrue( method_exists( \TenupFramework\BlockRegistrar::class, 'get_block_source' ) );
+ $this->assertTrue( method_exists( \TenupFramework\BlockRegistrar::class, 'get_all_block_sources' ) );
+
+ // Test initial state
+ $this->assertFalse( \TenupFramework\BlockRegistrar::has_block_conflict( 'test/block' ) );
+ $this->assertNull( \TenupFramework\BlockRegistrar::get_block_source( 'test/block' ) );
+ $this->assertIsArray( \TenupFramework\BlockRegistrar::get_all_block_sources() );
+ }
+
+ /**
+ * Test block source tracking.
+ *
+ * @return void
+ */
+ public function test_block_source_tracking() {
+ // Manually add a block source for testing
+ \TenupFramework\BlockRegistrar::$block_sources['test/block'] = 'TestClass';
+
+ // Test conflict detection
+ $this->assertTrue( \TenupFramework\BlockRegistrar::has_block_conflict( 'test/block' ) );
+ $this->assertEquals( 'TestClass', \TenupFramework\BlockRegistrar::get_block_source( 'test/block' ) );
+
+ // Test getting all sources
+ $sources = \TenupFramework\BlockRegistrar::get_all_block_sources();
+ $this->assertArrayHasKey( 'test/block', $sources );
+ $this->assertEquals( 'TestClass', $sources['test/block'] );
+
+ // Clean up
+ unset( \TenupFramework\BlockRegistrar::$block_sources['test/block'] );
+ }
+
+ /**
+ * Test path validation edge cases.
+ *
+ * @return void
+ */
+ public function test_path_validation_edge_cases() {
+ $block_registrar = new TestBlockRegistrar();
+
+ // Use reflection to access protected method
+ $reflection = new \ReflectionClass( $block_registrar );
+ $method = $reflection->getMethod( 'validate_directory_path' );
+ $method->setAccessible( true );
+
+ // Test invalid paths
+ $this->assertFalse( $method->invoke( $block_registrar, '' ) );
+ $this->assertFalse( $method->invoke( $block_registrar, '../malicious' ) );
+ $this->assertFalse( $method->invoke( $block_registrar, './relative' ) );
+ $this->assertFalse( $method->invoke( $block_registrar, str_repeat( 'a', 1001 ) ) );
+
+ // Test valid paths
+ $this->assertEquals( '/valid/path/', $method->invoke( $block_registrar, '/valid/path' ) );
+ $this->assertEquals( '/valid/path/', $method->invoke( $block_registrar, '/valid/path/' ) );
+ $this->assertEquals( '/valid/path/', $method->invoke( $block_registrar, '\\valid\\path' ) );
+ }
+
+ /**
+ * Test block.json validation edge cases.
+ *
+ * @return void
+ */
+ public function test_block_json_validation_edge_cases() {
+ $block_registrar = new TestBlockRegistrar();
+
+ // Use reflection to access protected method
+ $reflection = new \ReflectionClass( $block_registrar );
+ $method = $reflection->getMethod( 'validate_block_json' );
+ $method->setAccessible( true );
+
+ // Test invalid file paths
+ $this->assertFalse( $method->invoke( $block_registrar, '/non/existent/file.json' ) );
+
+ // Test invalid JSON (this would require creating actual files in tests)
+ // For now, just test that the method exists and handles errors
+ $this->assertTrue( method_exists( $block_registrar, 'validate_block_json' ) );
+ }
+
+ /**
+ * Test WordPress availability check.
+ *
+ * @return void
+ */
+ public function test_wordpress_availability_check() {
+ $block_registrar = new TestBlockRegistrar();
+
+ // Test that the method exists and can be called
+ // In a real WordPress environment, it would check for function availability
+ $this->assertTrue( method_exists( $block_registrar, 'register_blocks' ) );
+ }
+}
diff --git a/tests/Cache/ReadOnlyFileDiscoverCacheDriverTest.php b/tests/Cache/ReadOnlyFileDiscoverCacheDriverTest.php
new file mode 100644
index 0000000..23fd831
--- /dev/null
+++ b/tests/Cache/ReadOnlyFileDiscoverCacheDriverTest.php
@@ -0,0 +1,118 @@
+dir = sys_get_temp_dir() . '/tenup_ro_driver_' . uniqid( '', true );
+ mkdir( $this->dir );
+
+ return $this->dir;
+ }
+
+ /**
+ * Remove the temporary directory after a test.
+ *
+ * @return void
+ */
+ protected function tearDown(): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
+ if ( '' !== $this->dir && is_dir( $this->dir ) ) {
+ $files = glob( $this->dir . '/*' );
+ if ( false !== $files ) {
+ array_map( 'unlink', $files );
+ }
+ rmdir( $this->dir );
+ }
+ $this->dir = '';
+
+ parent::tearDown();
+ }
+
+ /**
+ * The constructor must not create the cache directory: the runtime never writes.
+ *
+ * @return void
+ */
+ public function test_constructor_does_not_create_directory() {
+ $missing = sys_get_temp_dir() . '/tenup_ro_missing_' . uniqid( '', true );
+
+ new ReadOnlyFileDiscoverCacheDriver( $missing, false, 'cache.php' );
+
+ $this->assertDirectoryDoesNotExist( $missing );
+ }
+
+ /**
+ * put() is a no-op: nothing is written to disk.
+ *
+ * @return void
+ */
+ public function test_put_writes_nothing() {
+ $dir = $this->make_dir();
+ $driver = new ReadOnlyFileDiscoverCacheDriver( $dir, false, 'cache.php' );
+
+ $driver->put( 'id', [ 'Foo\\Bar' ] );
+
+ $this->assertFalse( $driver->has( 'id' ) );
+ $this->assertFileDoesNotExist( $dir . '/cache.php' );
+ }
+
+ /**
+ * forget() is a no-op: an existing cache file is left untouched.
+ *
+ * @return void
+ */
+ public function test_forget_deletes_nothing() {
+ $dir = $this->make_dir();
+ file_put_contents( $dir . '/cache.php', 'forget( 'id' );
+
+ $this->assertFileExists( $dir . '/cache.php' );
+ }
+
+ /**
+ * has()/get() read an existing cache file produced with the same settings the
+ * build-time generator uses (serialize = false, an explicit filename).
+ *
+ * @return void
+ */
+ public function test_has_and_get_read_an_existing_file() {
+ $dir = $this->make_dir();
+ file_put_contents( $dir . '/cache.php', "assertTrue( $driver->has( 'id' ) );
+ $this->assertSame( [ 'Foo\\Bar' ], $driver->get( 'id' ) );
+ }
+}
diff --git a/tests/Core/EmojiTest.php b/tests/Core/EmojiTest.php
new file mode 100644
index 0000000..f9250f1
--- /dev/null
+++ b/tests/Core/EmojiTest.php
@@ -0,0 +1,226 @@
+assertInstanceOf( \TenupFramework\ModuleInterface::class, $emoji );
+ }
+
+ /**
+ * Test that Emoji can be registered.
+ *
+ * @return void
+ */
+ public function test_can_register() {
+ $emoji = new Emoji();
+
+ $this->assertTrue( $emoji->can_register() );
+ }
+
+ /**
+ * Test that Emoji has correct load order.
+ *
+ * @return void
+ */
+ public function test_load_order() {
+ $emoji = new Emoji();
+
+ $this->assertEquals( 5, $emoji->load_order() );
+ }
+
+ /**
+ * Test that register method can be called without errors.
+ *
+ * @return void
+ */
+ public function test_register_can_be_called() {
+ $emoji = new Emoji();
+
+ // Mock WordPress functions to prevent actual function calls
+ \Brain\Monkey\Functions\when( 'remove_action' )->justReturn( true );
+ \Brain\Monkey\Functions\when( 'remove_filter' )->justReturn( true );
+ \Brain\Monkey\Functions\when( 'add_filter' )->justReturn( true );
+
+ // This should not throw any exceptions
+ $emoji->register();
+
+ // If we get here, the method executed successfully
+ $this->assertTrue( true );
+ }
+
+ /**
+ * Test that register method exists and is callable.
+ *
+ * @return void
+ */
+ public function test_register_method_exists() {
+ $emoji = new Emoji();
+
+ $this->assertTrue( method_exists( $emoji, 'register' ) );
+ $this->assertTrue( is_callable( [ $emoji, 'register' ] ) );
+ }
+
+ /**
+ * Test that Emoji has the expected WordPress function calls in register method.
+ *
+ * @return void
+ */
+ public function test_register_method_contains_expected_calls() {
+ $reflection = new \ReflectionClass( Emoji::class );
+ $method = $reflection->getMethod( 'register' );
+ $filename = $method->getFileName();
+ $start_line = $method->getStartLine();
+ $end_line = $method->getEndLine();
+
+ // Read the method source code
+ $lines = file( $filename );
+ $method_source = implode( '', array_slice( $lines, $start_line - 1, $end_line - $start_line + 1 ) );
+
+ // Verify the method contains the expected remove_action calls
+ $this->assertStringContainsString( "remove_action( 'wp_head', 'print_emoji_detection_script', 7 )", $method_source );
+ $this->assertStringContainsString( "remove_action( 'admin_print_scripts', 'print_emoji_detection_script' )", $method_source );
+ $this->assertStringContainsString( "remove_action( 'wp_print_styles', 'print_emoji_styles' )", $method_source );
+ $this->assertStringContainsString( "remove_action( 'admin_print_styles', 'print_emoji_styles' )", $method_source );
+
+ // Verify the method contains the expected remove_filter calls
+ $this->assertStringContainsString( "remove_filter( 'the_content_feed', 'wp_staticize_emoji' )", $method_source );
+ $this->assertStringContainsString( "remove_filter( 'comment_text_rss', 'wp_staticize_emoji' )", $method_source );
+ $this->assertStringContainsString( "remove_filter( 'wp_mail', 'wp_staticize_emoji_for_email' )", $method_source );
+
+ // Verify the method contains the expected add_filter calls
+ $this->assertStringContainsString( "add_filter( 'tiny_mce_plugins', [ \$this, 'disable_emojis_tinymce' ] )", $method_source );
+ $this->assertStringContainsString( "add_filter( 'wp_resource_hints', [ \$this, 'disable_emoji_dns_prefetch' ], 10, 2 )", $method_source );
+ }
+
+ /**
+ * Test that disable_emojis_tinymce method exists and is callable.
+ *
+ * @return void
+ */
+ public function test_disable_emojis_tinymce_method_exists() {
+ $emoji = new Emoji();
+
+ $this->assertTrue( method_exists( $emoji, 'disable_emojis_tinymce' ) );
+ $this->assertTrue( is_callable( [ $emoji, 'disable_emojis_tinymce' ] ) );
+ }
+
+ /**
+ * Test that disable_emoji_dns_prefetch method exists and is callable.
+ *
+ * @return void
+ */
+ public function test_disable_emoji_dns_prefetch_method_exists() {
+ $emoji = new Emoji();
+
+ $this->assertTrue( method_exists( $emoji, 'disable_emoji_dns_prefetch' ) );
+ $this->assertTrue( is_callable( [ $emoji, 'disable_emoji_dns_prefetch' ] ) );
+ }
+
+ /**
+ * Test disable_emojis_tinymce method functionality.
+ *
+ * @return void
+ */
+ public function test_disable_emojis_tinymce_functionality() {
+ $emoji = new Emoji();
+
+ // Test with wpemoji plugin present
+ $plugins_with_emoji = [ 'wordpress', 'wpemoji', 'media' ];
+ $result = $emoji->disable_emojis_tinymce( $plugins_with_emoji );
+
+ $this->assertNotContains( 'wpemoji', $result );
+ $this->assertContains( 'wordpress', $result );
+ $this->assertContains( 'media', $result );
+
+ // Test with wpemoji plugin not present
+ $plugins_without_emoji = [ 'wordpress', 'media' ];
+ $result = $emoji->disable_emojis_tinymce( $plugins_without_emoji );
+
+ $this->assertEquals( $plugins_without_emoji, $result );
+ }
+
+ /**
+ * Test disable_emoji_dns_prefetch method functionality.
+ *
+ * @return void
+ */
+ public function test_disable_emoji_dns_prefetch_functionality() {
+ $emoji = new Emoji();
+
+ // Mock apply_filters for emoji_svg_url
+ \Brain\Monkey\Filters\expectApplied( 'emoji_svg_url' )
+ ->once()
+ ->andReturn( 'https://s.w.org/images/core/emoji/2/svg/' );
+
+ $urls = [
+ 'https://fonts.googleapis.com',
+ 'https://s.w.org/images/core/emoji/2/svg/',
+ 'https://example.com',
+ ];
+
+ $result = $emoji->disable_emoji_dns_prefetch( $urls, 'dns-prefetch' );
+
+ $this->assertNotContains( 'https://s.w.org/images/core/emoji/2/svg/', $result );
+ $this->assertContains( 'https://fonts.googleapis.com', $result );
+ $this->assertContains( 'https://example.com', $result );
+
+ // Test with different relation type
+ $result = $emoji->disable_emoji_dns_prefetch( $urls, 'preconnect' );
+ $this->assertEquals( $urls, $result );
+ }
+
+ /**
+ * Test that Emoji can be instantiated multiple times.
+ *
+ * @return void
+ */
+ public function test_multiple_instances() {
+ $emoji_1 = new Emoji();
+ $emoji_2 = new Emoji();
+
+ $this->assertInstanceOf( Emoji::class, $emoji_1 );
+ $this->assertInstanceOf( Emoji::class, $emoji_2 );
+ $this->assertNotSame( $emoji_1, $emoji_2 );
+ }
+
+ /**
+ * Test that Emoji uses the Module trait.
+ *
+ * @return void
+ */
+ public function test_uses_module_trait() {
+ $emoji = new Emoji();
+
+ // Check that the class has the methods from the Module trait
+ $this->assertTrue( method_exists( $emoji, 'load_order' ) );
+ $this->assertTrue( method_exists( $emoji, 'can_register' ) );
+ $this->assertTrue( method_exists( $emoji, 'register' ) );
+ }
+}
diff --git a/tests/Core/HeadOverridesTest.php b/tests/Core/HeadOverridesTest.php
new file mode 100644
index 0000000..b15d940
--- /dev/null
+++ b/tests/Core/HeadOverridesTest.php
@@ -0,0 +1,137 @@
+assertInstanceOf( \TenupFramework\ModuleInterface::class, $head_overrides );
+ }
+
+ /**
+ * Test that HeadOverrides can be registered.
+ *
+ * @return void
+ */
+ public function test_can_register() {
+ $head_overrides = new HeadOverrides();
+
+ $this->assertTrue( $head_overrides->can_register() );
+ }
+
+ /**
+ * Test that HeadOverrides has correct load order.
+ *
+ * @return void
+ */
+ public function test_load_order() {
+ $head_overrides = new HeadOverrides();
+
+ $this->assertEquals( 5, $head_overrides->load_order() );
+ }
+
+ /**
+ * Test that register method can be called without errors.
+ *
+ * @return void
+ */
+ public function test_register_can_be_called() {
+ $head_overrides = new HeadOverrides();
+
+ // Mock remove_action to prevent actual WordPress function calls
+ \Brain\Monkey\Functions\when( 'remove_action' )->justReturn( true );
+
+ // This should not throw any exceptions
+ $head_overrides->register();
+
+ // If we get here, the method executed successfully
+ $this->assertTrue( true );
+ }
+
+ /**
+ * Test that register method exists and is callable.
+ *
+ * @return void
+ */
+ public function test_register_method_exists() {
+ $head_overrides = new HeadOverrides();
+
+ $this->assertTrue( method_exists( $head_overrides, 'register' ) );
+ $this->assertTrue( is_callable( [ $head_overrides, 'register' ] ) );
+ }
+
+ /**
+ * Test that HeadOverrides has the expected WordPress function calls in register method.
+ *
+ * @return void
+ */
+ public function test_register_method_contains_expected_calls() {
+ $reflection = new \ReflectionClass( HeadOverrides::class );
+ $method = $reflection->getMethod( 'register' );
+ $filename = $method->getFileName();
+ $start_line = $method->getStartLine();
+ $end_line = $method->getEndLine();
+
+ // Read the method source code
+ $lines = file( $filename );
+ $method_source = implode( '', array_slice( $lines, $start_line - 1, $end_line - $start_line + 1 ) );
+
+ // Verify the method contains the expected remove_action calls
+ $this->assertStringContainsString( "remove_action( 'wp_head', 'wp_generator' )", $method_source );
+ $this->assertStringContainsString( "remove_action( 'wp_head', 'wlwmanifest_link' )", $method_source );
+ $this->assertStringContainsString( "remove_action( 'wp_head', 'rsd_link' )", $method_source );
+ }
+
+ /**
+ * Test that HeadOverrides can be instantiated multiple times.
+ *
+ * @return void
+ */
+ public function test_multiple_instances() {
+ $head_overrides_1 = new HeadOverrides();
+ $head_overrides_2 = new HeadOverrides();
+
+ $this->assertInstanceOf( HeadOverrides::class, $head_overrides_1 );
+ $this->assertInstanceOf( HeadOverrides::class, $head_overrides_2 );
+ $this->assertNotSame( $head_overrides_1, $head_overrides_2 );
+ }
+
+ /**
+ * Test that HeadOverrides uses the Module trait.
+ *
+ * @return void
+ */
+ public function test_uses_module_trait() {
+ $head_overrides = new HeadOverrides();
+
+ // Check that the class has the methods from the Module trait
+ $this->assertTrue( method_exists( $head_overrides, 'load_order' ) );
+ $this->assertTrue( method_exists( $head_overrides, 'can_register' ) );
+ $this->assertTrue( method_exists( $head_overrides, 'register' ) );
+ }
+}
diff --git a/tests/Debug/LoaderDebugTest.php b/tests/Debug/LoaderDebugTest.php
new file mode 100644
index 0000000..6eab4fd
--- /dev/null
+++ b/tests/Debug/LoaderDebugTest.php
@@ -0,0 +1,522 @@
+
+ */
+ private function sample_record( string $directory = '/srv/site/wp-content/plugins/demo/inc' ): array {
+ return [
+ 'directory' => $directory,
+ 'cache_file' => $directory . '/class-loader-cache/class-loader-cache-v2.php',
+ 'cache_exists' => false,
+ 'cache_used' => false,
+ 'cache_disabled' => false,
+ 'classes' => [ 'TenupTmp\\Widget' ],
+ 'version' => '1.3.0',
+ 'reference' => 'abcdef1234567890',
+ 'discovery_seconds' => 0.0123,
+ 'lookup_seconds' => 0.0456,
+ ];
+ }
+
+ /**
+ * Stub the functions record() needs, with the tooling enabled.
+ *
+ * @return void
+ */
+ private function stub_enabled() {
+ when( 'add_action' )->justReturn( true );
+ when( 'add_filter' )->justReturn( true );
+ when( 'apply_filters' )->returnArg( 2 );
+ }
+
+ /**
+ * is_enabled() is false when the enable filter returns false.
+ *
+ * @return void
+ */
+ public function test_is_enabled_false_when_filter_disables() {
+ when( 'add_action' )->justReturn( true );
+ when( 'apply_filters' )->justReturn( false );
+
+ $this->assertFalse( LoaderDebug::is_enabled() );
+ }
+
+ /**
+ * is_enabled() is false when the disable constant is set.
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ *
+ * @return void
+ */
+ public function test_is_enabled_false_when_constant_defined() {
+ when( 'add_action' )->justReturn( true );
+ when( 'apply_filters' )->returnArg( 2 );
+
+ define( 'TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG', true );
+
+ $this->assertFalse( LoaderDebug::is_enabled() );
+ }
+
+ /**
+ * record() stores the record when enabled.
+ *
+ * @return void
+ */
+ public function test_record_stores_when_enabled() {
+ $this->stub_enabled();
+
+ LoaderDebug::record( $this->sample_record() );
+
+ $this->assertCount( 1, LoaderDebug::get_loaders() );
+ }
+
+ /**
+ * record() accumulates multiple records.
+ *
+ * @return void
+ */
+ public function test_records_accumulate() {
+ $this->stub_enabled();
+
+ LoaderDebug::record( $this->sample_record( '/a/inc' ) );
+ LoaderDebug::record( $this->sample_record( '/b/inc' ) );
+
+ $this->assertCount( 2, LoaderDebug::get_loaders() );
+ }
+
+ /**
+ * record() stores nothing when disabled.
+ *
+ * @return void
+ */
+ public function test_record_skips_when_disabled() {
+ when( 'add_action' )->justReturn( true );
+ when( 'apply_filters' )->justReturn( false );
+
+ LoaderDebug::record( $this->sample_record() );
+
+ $this->assertSame( [], LoaderDebug::get_loaders() );
+ }
+
+ /**
+ * The callback registered on the aggregation filter merges this copy's records into
+ * whatever other copies have already contributed.
+ *
+ * @return void
+ */
+ public function test_aggregation_filter_merges_records() {
+ $captured = null;
+
+ when( 'add_action' )->justReturn( true );
+ when( 'apply_filters' )->returnArg( 2 );
+ when( 'add_filter' )->alias(
+ static function ( $hook, $callback ) use ( &$captured ) {
+ if ( LoaderDebug::FILTER === $hook ) {
+ $captured = $callback;
+ }
+ return true;
+ }
+ );
+
+ LoaderDebug::record( $this->sample_record( '/b/inc' ) );
+
+ $this->assertIsCallable( $captured );
+
+ // A record contributed by another copy should be preserved alongside ours.
+ $existing = [ [ 'directory' => '/a/inc' ] ];
+ $merged = $captured( $existing );
+
+ $this->assertCount( 2, $merged );
+ $this->assertSame( '/a/inc', $merged[0]['directory'] );
+ $this->assertSame( '/b/inc', $merged[1]['directory'] );
+ }
+
+ /**
+ * render_page() lists each loader and the classes it loaded.
+ *
+ * @return void
+ */
+ public function test_render_page_lists_loaders_and_classes() {
+ $this->stub_render_environment();
+
+ LoaderDebug::record( $this->sample_record() );
+
+ $output = $this->capture_render();
+
+ $this->assertStringContainsString( 'WP Framework Loaders', $output );
+ $this->assertStringContainsString( '/srv/site/wp-content/plugins/demo/inc', $output );
+ $this->assertStringContainsString( 'TenupTmp\\Widget', $output );
+ $this->assertStringContainsString( 'Check this cache for staleness', $output );
+
+ // The recorded discovery/lookup timings are surfaced on the page.
+ $this->assertStringContainsString( 'Discovery time', $output );
+ $this->assertStringContainsString( 'Class lookup time', $output );
+ $this->assertStringContainsString( '12.30 ms', $output ); // 0.0123s discovery.
+ $this->assertStringContainsString( '45.60 ms', $output ); // 0.0456s lookup.
+ }
+
+ /**
+ * owner_label() derives a plugin name when the directory sits under the plugins root.
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ *
+ * @return void
+ */
+ public function test_owner_label_derives_plugin_name() {
+ define( 'WP_PLUGIN_DIR', '/srv/site/wp-content/plugins' );
+
+ $method = ( new \ReflectionClass( LoaderDebug::class ) )->getMethod( 'owner_label' );
+ $method->setAccessible( true );
+
+ $this->assertSame(
+ 'Plugin: demo',
+ $method->invoke( null, '/srv/site/wp-content/plugins/demo/inc' )
+ );
+ }
+
+ /**
+ * render_page() reports drift when a staleness check is requested with a valid nonce.
+ *
+ * @return void
+ */
+ public function test_render_page_reports_staleness_drift() {
+ $this->stub_render_environment();
+ when( 'wp_verify_nonce' )->justReturn( true );
+
+ $dir = $this->make_temp_class_dir();
+
+ // The loaded list (in the record) is deliberately out of date versus what is on disk.
+ $record = $this->sample_record( $dir );
+ $record['classes'] = [ 'TenupTmp\\Old' ];
+ LoaderDebug::record( $record );
+
+ $_GET['check'] = md5( $dir );
+ $_GET['_wpnonce'] = 'test';
+
+ $output = $this->capture_render();
+
+ unset( $_GET['check'], $_GET['_wpnonce'] );
+ $this->remove_temp_dir( $dir );
+
+ $this->assertStringContainsString( 'Stale', $output );
+ $this->assertStringContainsString( 'TenupTmp\\Widget', $output ); // On disk, missing from cache.
+ $this->assertStringContainsString( 'TenupTmp\\Old', $output ); // In cache, gone from disk.
+ // The drift notice also reports a real, positive live-discovery duration.
+ $this->assertMatchesRegularExpression( '/Live discovery took \d[\d.,]* (ms|s)\./', $output );
+ }
+
+ /**
+ * render_page() confirms an up-to-date cache and reports how long the live scan took when a
+ * staleness check is requested and the loaded list matches disk.
+ *
+ * @return void
+ */
+ public function test_render_page_reports_up_to_date_and_timing() {
+ $this->stub_render_environment();
+ when( 'wp_verify_nonce' )->justReturn( true );
+
+ $dir = $this->make_temp_class_dir();
+
+ // The loaded list matches what is actually on disk (the single Widget class).
+ $record = $this->sample_record( $dir );
+ $record['classes'] = [ 'TenupTmp\\Widget' ];
+ LoaderDebug::record( $record );
+
+ $_GET['check'] = md5( $dir );
+ $_GET['_wpnonce'] = 'test';
+
+ $output = $this->capture_render();
+
+ unset( $_GET['check'], $_GET['_wpnonce'] );
+ $this->remove_temp_dir( $dir );
+
+ $this->assertStringContainsString( 'Up to date', $output );
+ // Require a real, positive duration — this must NOT match the "Live discovery took —."
+ // placeholder that format_duration() emits for a non-positive/absent value.
+ $this->assertMatchesRegularExpression( '/Live discovery took \d[\d.,]* (ms|s)\./', $output );
+ }
+
+ /**
+ * cache_state() maps each combination of the record flags to the expected severity and badge.
+ *
+ * @dataProvider cache_state_provider
+ *
+ * @param array $flags The cache_* flags to set on the record.
+ * @param string $expected_sev The expected severity.
+ * @param string $expected_snippet A substring expected in the badge.
+ *
+ * @return void
+ */
+ public function test_cache_state_resolves_expected_states( array $flags, string $expected_sev, string $expected_snippet ) {
+ $state = $this->invoke_protected( 'cache_state', [ array_merge( $this->sample_record(), $flags ) ] );
+
+ $this->assertSame( $expected_sev, $state['severity'] );
+ $this->assertStringContainsString( $expected_snippet, $state['badge'] );
+ }
+
+ /**
+ * Data for test_cache_state_resolves_expected_states.
+ *
+ * @return array, 1: string, 2: string}>
+ */
+ public function cache_state_provider(): array {
+ return [
+ 'disabled' => [ [ 'cache_disabled' => true ], 'warn', 'disabled' ],
+ 'uncached' => [
+ [
+ 'cache_exists' => false,
+ 'cache_used' => false,
+ ],
+ 'warn',
+ 'Uncached',
+ ],
+ 'present but unused' => [
+ [
+ 'cache_exists' => true,
+ 'cache_used' => false,
+ ],
+ 'error',
+ 'not used',
+ ],
+ 'failed to load' => [
+ [
+ 'cache_exists' => true,
+ 'cache_used' => false,
+ 'cache_failed' => true,
+ ],
+ 'error',
+ 'failed to load',
+ ],
+ 'in use' => [
+ [
+ 'cache_exists' => true,
+ 'cache_used' => true,
+ ],
+ 'ok',
+ 'in use',
+ ],
+ ];
+ }
+
+ /**
+ * legacy_files() reports files in the cache directory that are not the current cache file,
+ * and nothing when the directory is clean or absent.
+ *
+ * @return void
+ */
+ public function test_legacy_files_detects_unexpected_files() {
+ $dir = $this->make_temp_class_dir();
+ $cache_dir = $dir . '/class-loader-cache';
+ mkdir( $cache_dir );
+
+ $current = $cache_dir . '/class-loader-cache-v2.php';
+ file_put_contents( $current, 'assertSame( [], $this->invoke_protected( 'legacy_files', [ $current ] ) );
+
+ // A leftover file from an older version is reported.
+ file_put_contents( $cache_dir . '/discoverer-cache-TenupFramework', 'x' );
+ $found = $this->invoke_protected( 'legacy_files', [ $current ] );
+ $this->assertContains( 'discoverer-cache-TenupFramework', $found );
+ $this->assertNotContains( 'class-loader-cache-v2.php', $found );
+
+ $this->remove_temp_dir( $dir );
+ }
+
+ /**
+ * legacy_files() is empty when the cache directory does not exist.
+ *
+ * @return void
+ */
+ public function test_legacy_files_empty_when_directory_absent() {
+ $missing = sys_get_temp_dir() . '/tenup_missing_' . uniqid( '', true ) . '/class-loader-cache-v2.php';
+
+ $this->assertSame( [], $this->invoke_protected( 'legacy_files', [ $missing ] ) );
+ }
+
+ /**
+ * format_duration() picks a sensible unit and renders a placeholder for non-positive input.
+ *
+ * @dataProvider duration_provider
+ *
+ * @param mixed $seconds The duration in seconds.
+ * @param string $expected The expected rendered string.
+ *
+ * @return void
+ */
+ public function test_format_duration( $seconds, string $expected ) {
+ $this->assertSame( $expected, $this->invoke_protected( 'format_duration', [ $seconds ] ) );
+ }
+
+ /**
+ * Data for test_format_duration.
+ *
+ * @return array
+ */
+ public function duration_provider(): array {
+ return [
+ 'zero' => [ 0.0, '—' ],
+ 'negative' => [ -0.005, '—' ],
+ 'non-numeric' => [ 'nope', '—' ],
+ 'not-a-number' => [ NAN, '—' ],
+ 'infinite' => [ INF, '—' ],
+ 'sub-milli' => [ 0.0004, '0.400 ms' ],
+ 'milliseconds' => [ 0.0123, '12.30 ms' ],
+ 'seconds' => [ 1.5, '1.50 s' ],
+ ];
+ }
+
+ /**
+ * cache_detail() renders "Built ago · · " with the build time in UTC.
+ *
+ * @return void
+ */
+ public function test_cache_detail_shows_size_and_utc_build_time() {
+ when( 'human_time_diff' )->justReturn( '5 minutes' );
+ when( 'size_format' )->alias( static fn( $bytes ) => $bytes . ' B' );
+
+ $dir = $this->make_temp_class_dir();
+ $cache_dir = $dir . '/class-loader-cache';
+ mkdir( $cache_dir );
+ $cache_file = $cache_dir . '/class-loader-cache-v2.php';
+ file_put_contents( $cache_file, 'invoke_protected( 'cache_detail', [ [ 'cache_file' => $cache_file ] ] );
+
+ $this->assertStringContainsString( 'Built 5 minutes ago', $detail );
+ // The absolute build time is the file mtime rendered in UTC as the trailing segment.
+ $expected_utc = gmdate( 'Y-m-d H:i:s', (int) filemtime( $cache_file ) ) . ' UTC';
+ $this->assertStringContainsString( '· ' . $expected_utc, $detail );
+ $this->assertMatchesRegularExpression( '/·\s*\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/', $detail );
+
+ $this->remove_temp_dir( $dir );
+ }
+
+ /**
+ * Invoke a protected static method on LoaderDebug via reflection.
+ *
+ * @param string $method The method name.
+ * @param array $args The arguments.
+ *
+ * @return mixed
+ */
+ private function invoke_protected( string $method, array $args ) {
+ $reflection = ( new \ReflectionClass( LoaderDebug::class ) )->getMethod( $method );
+ $reflection->setAccessible( true );
+
+ return $reflection->invokeArgs( null, $args );
+ }
+
+ /**
+ * Stub everything render_page() touches, with the tooling enabled and the current user
+ * capable. apply_filters returns this copy's records for the aggregation filter.
+ *
+ * @return void
+ */
+ private function stub_render_environment() {
+ when( 'add_action' )->justReturn( true );
+ when( 'add_filter' )->justReturn( true );
+ when( 'current_user_can' )->justReturn( true );
+ when( 'sanitize_text_field' )->returnArg( 1 );
+ when( 'wp_unslash' )->returnArg( 1 );
+ when( 'admin_url' )->alias(
+ static function ( $path = '' ) {
+ return 'http://example.test/wp-admin/' . $path;
+ }
+ );
+ when( 'add_query_arg' )->alias(
+ static function ( $args, $url ) {
+ return $url . '?' . http_build_query( (array) $args );
+ }
+ );
+ when( 'wp_nonce_url' )->returnArg( 1 );
+ when( 'apply_filters' )->alias(
+ static function ( $hook, $value = null ) {
+ if ( LoaderDebug::FILTER === $hook ) {
+ return LoaderDebug::get_loaders();
+ }
+ return $value;
+ }
+ );
+ }
+
+ /**
+ * Capture the output of render_page().
+ *
+ * @return string
+ */
+ private function capture_render(): string {
+ ob_start();
+ LoaderDebug::render_page();
+ return (string) ob_get_clean();
+ }
+
+ /**
+ * Create a temporary directory containing a single discoverable class.
+ *
+ * @return string The created directory path.
+ */
+ private function make_temp_class_dir(): string {
+ $dir = sys_get_temp_dir() . '/tenup_loader_debug_' . uniqid( '', true );
+ mkdir( $dir );
+ file_put_contents( $dir . '/Widget.php', "isDir() ) {
+ rmdir( $item->getPathname() );
+ } else {
+ unlink( $item->getPathname() );
+ }
+ }
+
+ rmdir( $dir );
+ }
+}
diff --git a/tests/FrameworkTestSetup.php b/tests/FrameworkTestSetup.php
index 587344b..d879f85 100644
--- a/tests/FrameworkTestSetup.php
+++ b/tests/FrameworkTestSetup.php
@@ -53,6 +53,9 @@ protected function setUp(): void { // phpcs:ignore WordPress.NamingConventions.V
stubs(
[
'wp_get_environment_type' => 'local',
+ // Default to the front end so existing tests don't trigger admin-only debug
+ // recording; admin tests override this with their own stub.
+ 'is_admin' => false,
'sanitize_title' => function ( $title ) {
return str_replace( ' ', '-', strtolower( $title ) );
},
@@ -68,6 +71,51 @@ protected function setUp(): void { // phpcs:ignore WordPress.NamingConventions.V
stubEscapeFunctions();
stubTranslationFunctions();
+
+ $this->reset_module_initialization();
+ $this->reset_loader_debug();
+ }
+
+ /**
+ * Reset the ModuleInitialization singleton so its accumulated `$classes` do not leak between
+ * tests. The suite is not process-isolated (the trait-level annotation does not take effect),
+ * so without this a class registered in one test would be seen as "already initialized" in a
+ * later one.
+ *
+ * @return void
+ */
+ protected function reset_module_initialization(): void {
+ if ( ! class_exists( \TenupFramework\ModuleInitialization::class ) ) {
+ return;
+ }
+
+ $instance = ( new \ReflectionClass( \TenupFramework\ModuleInitialization::class ) )->getProperty( 'instance' );
+ $instance->setAccessible( true );
+ $instance->setValue( null, null );
+ }
+
+ /**
+ * Reset the static state of the LoaderDebug registry so each test starts clean,
+ * independent of test execution order or process isolation.
+ *
+ * @return void
+ */
+ protected function reset_loader_debug(): void {
+ if ( ! class_exists( \TenupFramework\Debug\LoaderDebug::class ) ) {
+ return;
+ }
+
+ $reflection = new \ReflectionClass( \TenupFramework\Debug\LoaderDebug::class );
+
+ $loaders = $reflection->getProperty( 'loaders' );
+ $loaders->setAccessible( true );
+ $loaders->setValue( null, [] );
+
+ $booted = $reflection->getProperty( 'booted' );
+ $booted->setAccessible( true );
+ $booted->setValue( null, false );
+
+ unset( $GLOBALS['tenup_framework_debug_page_registered'] );
}
/**
diff --git a/tests/ModuleInitializationTest.php b/tests/ModuleInitializationTest.php
index 2984b67..5f054ff 100644
--- a/tests/ModuleInitializationTest.php
+++ b/tests/ModuleInitializationTest.php
@@ -10,7 +10,7 @@
namespace TenupFrameworkTests;
use PHPUnit\Framework\TestCase;
-use function Brain\Monkey\Functions\stubs;
+use function Brain\Monkey\Functions\when;
/**
* Test Class
@@ -42,11 +42,14 @@ public function test_it_can_find_classes() {
*/
public function test_it_can_find_classes_to_register() {
$class = \TenupFramework\ModuleInitialization::instance();
- $class->init_classes( dirname( __DIR__, 1 ) . '/src/' );
+ $class->init_classes( dirname( __DIR__, 1 ) . '/fixtures/classes' );
$classes = $class->get_all_classes();
- // Check that we have only classes that extend Module and more than 0.
- $this->assertGreaterThanOrEqual( 0, count( $classes ) );
+ // The registered set is non-empty and contains only ModuleInterface implementations.
+ $this->assertNotEmpty( $classes );
+ foreach ( $classes as $registered ) {
+ $this->assertInstanceOf( \TenupFramework\ModuleInterface::class, $registered );
+ }
}
/**
@@ -151,72 +154,361 @@ public function testIsClassFullyLoadable() {
/**
- * Ensure it returns false if VIP_GO_APP_ENVIRONMENT is defined.
+ * generate_cache() writes a readable cache file and returns the discovered classes.
+ *
+ * @return void
+ */
+ public function test_generate_cache_writes_a_readable_cache_file() {
+ $dir = $this->make_temp_class_dir();
+
+ $module_init = \TenupFramework\ModuleInitialization::instance();
+ $cached = $module_init->generate_cache( $dir );
+
+ $this->assertFileExists( $this->cache_file_path( $dir ) );
+ $this->assertContains( 'TenupTmp\\Widget', $cached );
+
+ $this->remove_temp_dir( $dir );
+ }
+
+ /**
+ * The runtime read path uses the cache file when one is present.
*
* @return void
*/
- public function test_should_use_cache_returns_false_when_vip_env_is_defined() {
- define( 'VIP_GO_APP_ENVIRONMENT', true );
+ public function test_get_classes_reads_the_cache_file_when_present() {
+ $dir = $this->make_temp_class_dir();
+
$module_init = \TenupFramework\ModuleInitialization::instance();
- $reflection = new \ReflectionClass( $module_init );
- $method = $reflection->getMethod( 'should_use_cache' );
- $method->setAccessible( true );
+ $module_init->generate_cache( $dir );
+
+ // Tamper with the cache so we can prove the read path uses it rather than re-discovering.
+ $this->write_file( $this->cache_file_path( $dir ), "get_classes( $dir );
- $this->assertFalse( $method->invoke( $module_init ) );
+ $this->assertSame( [ 'TenupTmp\\Sentinel' ], array_values( $read ) );
+
+ $this->remove_temp_dir( $dir );
}
/**
- * Ensure it returns false in non-production or staging environments.
+ * With no cache present the runtime discovers live and writes nothing.
*
* @return void
*/
- public function test_should_use_cache_returns_false_in_non_production_or_staging_env() {
- stubs(
- [
- 'wp_get_environment_type' => 'development',
- ]
+ public function test_get_classes_creates_no_cache_when_none_exists() {
+ $dir = $this->make_temp_class_dir();
+
+ $module_init = \TenupFramework\ModuleInitialization::instance();
+ $classes = $module_init->get_classes( $dir );
+
+ $this->assertContains( 'TenupTmp\\Widget', $classes );
+ $this->assertDirectoryDoesNotExist( $dir . '/' . \TenupFramework\ModuleInitialization::CACHE_DIR_NAME );
+
+ $this->remove_temp_dir( $dir );
+ }
+
+ /**
+ * A cache written by an older framework version (a different filename) is ignored,
+ * so an upgraded site never serves a stale cache it cannot rewrite.
+ *
+ * @return void
+ */
+ public function test_get_classes_ignores_legacy_cache_file() {
+ $dir = $this->make_temp_class_dir();
+ $cache_dir = $dir . '/' . \TenupFramework\ModuleInitialization::CACHE_DIR_NAME;
+ mkdir( $cache_dir );
+
+ // The previous version wrote `discoverer-cache-{id}` as a serialized file.
+ $this->write_file( $cache_dir . '/discoverer-cache-TenupFramework', serialize( [ 'TenupTmp\\Legacy' ] ) );
+
+ $module_init = \TenupFramework\ModuleInitialization::instance();
+ $read = $module_init->get_classes( $dir );
+
+ $this->assertNotContains( 'TenupTmp\\Legacy', $read );
+ $this->assertContains( 'TenupTmp\\Widget', $read );
+
+ $this->remove_temp_dir( $dir );
+ }
+
+ /**
+ * A corrupt or truncated cache file does not fatal the request: get_classes() catches the
+ * error and falls back to a live discovery, so a bad cache degrades to uncached rather than
+ * taking the site down.
+ *
+ * @return void
+ */
+ public function test_get_classes_falls_back_to_live_when_cache_is_corrupt() {
+ $dir = $this->make_temp_class_dir();
+ $cache_dir = $dir . '/' . \TenupFramework\ModuleInitialization::CACHE_DIR_NAME;
+ mkdir( $cache_dir );
+
+ // A truncated / syntactically broken cache file — `require` on this throws a ParseError.
+ $this->write_file(
+ $cache_dir . '/' . \TenupFramework\ModuleInitialization::CACHE_FILENAME,
+ "getMethod( 'should_use_cache' );
- $method->setAccessible( true );
+ $read = $module_init->get_classes( $dir );
+
+ // Fell back to a live scan and still found the real class on disk.
+ $this->assertContains( 'TenupTmp\\Widget', $read );
- $this->assertFalse( $method->invoke( $module_init ) );
+ $this->remove_temp_dir( $dir );
}
/**
- * Ensure it returns false when TENUP_FRAMEWORK_DISABLE_CLASS_CACHE is defined.
+ * Defining TENUP_FRAMEWORK_DISABLE_CLASS_CACHE forces live discovery even when a
+ * cache file is present.
*
* @return void
*/
- public function test_should_use_cache_returns_false_when_disable_class_cache_is_defined() {
+ public function test_disable_constant_forces_live_discovery() {
+ $dir = $this->make_temp_class_dir();
+
+ $module_init = \TenupFramework\ModuleInitialization::instance();
+ $module_init->generate_cache( $dir );
+
+ // Tamper with the cache; with caching disabled this sentinel must not be read.
+ $this->write_file( $this->cache_file_path( $dir ), "get_classes( $dir );
+
+ $this->assertNotContains( 'TenupTmp\\Sentinel', $read );
+ $this->assertContains( 'TenupTmp\\Widget', $read );
+
+ $this->remove_temp_dir( $dir );
+ }
+
+ /**
+ * In the admin, init_classes() hands a loader record to the debug registry.
+ *
+ * @return void
+ */
+ public function test_init_classes_records_a_loader_in_admin() {
+ when( 'is_admin' )->justReturn( true );
+ when( 'add_action' )->justReturn( true );
+ when( 'add_filter' )->justReturn( true );
+ when( 'apply_filters' )->returnArg( 2 );
+
+ $dir = $this->make_temp_class_dir();
+
+ \TenupFramework\ModuleInitialization::instance()->init_classes( $dir );
+
+ $loaders = \TenupFramework\Debug\LoaderDebug::get_loaders();
+ $this->assertCount( 1, $loaders );
+ $this->assertSame( $dir, $loaders[0]['directory'] );
+ $this->assertContains( 'TenupTmp\\Widget', $loaders[0]['classes'] );
+
+ $this->remove_temp_dir( $dir );
+ }
+
+ /**
+ * With no cache present, init_classes() records the time of a genuine live (uncached)
+ * discovery — a real filesystem scan, so the recorded duration is strictly positive.
+ *
+ * @return void
+ */
+ public function test_init_classes_records_live_discovery_timing_in_admin() {
+ when( 'is_admin' )->justReturn( true );
+ when( 'add_action' )->justReturn( true );
+ when( 'add_filter' )->justReturn( true );
+ when( 'apply_filters' )->returnArg( 2 );
+
+ $dir = $this->make_temp_class_dir();
+
+ \TenupFramework\ModuleInitialization::instance()->init_classes( $dir );
+
+ $loaders = \TenupFramework\Debug\LoaderDebug::get_loaders();
+ $this->assertCount( 1, $loaders );
+ $this->assertFalse( $loaders[0]['cache_used'], 'No cache exists, so discovery must be live.' );
+ $this->assertIsFloat( $loaders[0]['discovery_seconds'] );
+ $this->assertIsFloat( $loaders[0]['lookup_seconds'] );
+ // A live filesystem scan and the reflection loop both take measurable time; the never-wired
+ // default is 0.0, so asserting strictly-positive proves the instrumentation actually ran.
+ $this->assertGreaterThan( 0.0, $loaders[0]['discovery_seconds'] );
+ $this->assertGreaterThan( 0.0, $loaders[0]['lookup_seconds'] );
+
+ $this->remove_temp_dir( $dir );
+ }
+
+ /**
+ * With a pre-built cache present, init_classes() reads it (cache_used) and still records a
+ * positive discovery duration — the cache-read cost rather than a live scan.
+ *
+ * @return void
+ */
+ public function test_init_classes_records_cache_read_timing_in_admin() {
+ when( 'is_admin' )->justReturn( true );
+ when( 'add_action' )->justReturn( true );
+ when( 'add_filter' )->justReturn( true );
+ when( 'apply_filters' )->returnArg( 2 );
+
+ $dir = $this->make_temp_class_dir();
+ $module = \TenupFramework\ModuleInitialization::instance();
+ $module->generate_cache( $dir );
+
+ $module->init_classes( $dir );
+
+ $loaders = \TenupFramework\Debug\LoaderDebug::get_loaders();
+ $this->assertCount( 1, $loaders );
+ $this->assertTrue( $loaders[0]['cache_used'], 'A cache file exists, so it should be used.' );
+ $this->assertGreaterThan( 0.0, $loaders[0]['discovery_seconds'] );
+
+ $this->remove_temp_dir( $dir );
+ }
+
+ /**
+ * On the front end, init_classes() records nothing (the data is only viewable in the admin).
+ *
+ * @return void
+ */
+ public function test_init_classes_records_nothing_on_the_front_end() {
+ when( 'is_admin' )->justReturn( false );
+
+ $dir = $this->make_temp_class_dir();
+
+ \TenupFramework\ModuleInitialization::instance()->init_classes( $dir );
+
+ $this->assertSame( [], \TenupFramework\Debug\LoaderDebug::get_loaders() );
+
+ $this->remove_temp_dir( $dir );
+ }
+
+ /**
+ * discover_live() ignores any cache file and returns the real on-disk classes.
+ *
+ * @return void
+ */
+ public function test_discover_live_ignores_the_cache() {
+ $dir = $this->make_temp_class_dir();
$module_init = \TenupFramework\ModuleInitialization::instance();
- $reflection = new \ReflectionClass( $module_init );
- $method = $reflection->getMethod( 'should_use_cache' );
- $method->setAccessible( true );
+ $module_init->generate_cache( $dir );
+
+ // Tamper with the cache; discover_live() must not read it.
+ $this->write_file( $this->cache_file_path( $dir ), "assertFalse( $method->invoke( $module_init ) );
+ $live = $module_init->discover_live( $dir );
+
+ $this->assertContains( 'TenupTmp\\Widget', $live );
+ $this->assertNotContains( 'TenupTmp\\Sentinel', $live );
+
+ $this->remove_temp_dir( $dir );
}
/**
- * Ensure it returns true under default conditions.
+ * A corrupt or truncated cache file falls back to a live scan instead of fataling.
*
* @return void
*/
- public function test_should_use_cache_returns_true_under_default_conditions() {
- stubs(
- [
- 'wp_get_environment_type' => 'production',
- ]
- );
+ public function test_get_classes_falls_back_when_the_cache_is_corrupt() {
+ $dir = $this->make_temp_class_dir();
+ $module_init = \TenupFramework\ModuleInitialization::instance();
+ $module_init->generate_cache( $dir );
+ // Truncated PHP: require() raises a ParseError, which the read path must catch.
+ $this->write_file( $this->cache_file_path( $dir ), 'get_classes( $dir );
+
+ $this->assertContains( 'TenupTmp\\Widget', $classes );
+
+ $this->remove_temp_dir( $dir );
+ }
+
+ /**
+ * When the read path falls back, the loader record flags the failure so the debug page can
+ * show it rather than reporting the cache as in use.
+ *
+ * @return void
+ */
+ public function test_corrupt_cache_records_a_failed_state() {
+ when( 'is_admin' )->justReturn( true );
+ when( 'add_action' )->justReturn( true );
+ when( 'add_filter' )->justReturn( true );
+ when( 'apply_filters' )->returnArg( 2 );
+
+ $dir = $this->make_temp_class_dir();
$module_init = \TenupFramework\ModuleInitialization::instance();
- $reflection = new \ReflectionClass( $module_init );
- $method = $reflection->getMethod( 'should_use_cache' );
- $method->setAccessible( true );
+ $module_init->generate_cache( $dir );
+ $this->write_file( $this->cache_file_path( $dir ), 'init_classes( $dir );
+
+ $loaders = \TenupFramework\Debug\LoaderDebug::get_loaders();
+ $this->assertNotEmpty( $loaders );
+ $this->assertTrue( $loaders[0]['cache_failed'] );
+ $this->assertFalse( $loaders[0]['cache_used'] );
+ $this->assertContains( 'TenupTmp\\Widget', $loaders[0]['classes'] );
+
+ $this->remove_temp_dir( $dir );
+ }
+
+ /**
+ * Build the absolute path to the cache file for a discovery directory.
+ *
+ * @param string $dir The discovery directory.
+ *
+ * @return string
+ */
+ private function cache_file_path( string $dir ): string {
+ return $dir . '/' . \TenupFramework\ModuleInitialization::CACHE_DIR_NAME
+ . '/' . \TenupFramework\ModuleInitialization::CACHE_FILENAME;
+ }
+
+ /**
+ * Create a temporary directory containing a single discoverable class.
+ *
+ * @return string The created directory path.
+ */
+ private function make_temp_class_dir(): string {
+ $dir = sys_get_temp_dir() . '/tenup_framework_test_' . uniqid( '', true );
+ mkdir( $dir );
+ $this->write_file( $dir . '/Widget.php', "assertNotFalse( file_put_contents( $path, $contents ) );
+ }
+
+ /**
+ * Recursively remove a temporary directory.
+ *
+ * @param string $dir The directory to remove.
+ *
+ * @return void
+ */
+ private function remove_temp_dir( string $dir ): void {
+ if ( ! is_dir( $dir ) ) {
+ return;
+ }
+
+ $items = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ),
+ \RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ( $items as $item ) {
+ if ( $item->isDir() ) {
+ rmdir( $item->getPathname() );
+ } else {
+ unlink( $item->getPathname() );
+ }
+ }
- $this->assertTrue( $method->invoke( $module_init ) );
+ rmdir( $dir );
}
}
diff --git a/tests/TestBlockRegistrar.php b/tests/TestBlockRegistrar.php
new file mode 100644
index 0000000..0167912
--- /dev/null
+++ b/tests/TestBlockRegistrar.php
@@ -0,0 +1,26 @@
+