diff --git a/CHANGELOG.md b/CHANGELOG.md
index 76515c7a..aa8c0ad5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,50 @@ All notable changes to the Pollora framework will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [Unreleased](https://github.com/Pollora/framework/compare/v13.35.6...develop)
+## [Unreleased](https://github.com/Pollora/framework/compare/v13.36.0...develop)
+
+## [v13.36.0](https://github.com/Pollora/framework/compare/v13.35.6...v13.36.0) - 2026-10-14
+
+### Added
+- Discovery lives in its own package, [`pollora/discovery`](https://github.com/Pollora/discovery), which the framework requires: the engine, its contracts, `DiscoveryManager`, the cache and the `#[SkipDiscovery]` attribute, with their tests and benchmarks. Class names do not change. The framework keeps what needs Laravel: the service provider, `discovery:run` / `discovery:clear`, `DiscoveryRegistrar`, `ServiceProviderDiscovery`, the Laravel adapters, and `HasConfiguringSupport`, which builds post types and taxonomies
+- Discovery can run without Laravel's application, the first step towards `pollora/discovery`: `new DiscoveryEngine()` works with no argument. Two contracts let an environment plug in — `InstanceResolverInterface` builds the classes discovery needs (Laravel's container in a Pollora application, `ReflectionInstanceResolver` otherwise) and `DiscoveryRegistrarInterface` adds the environment's discoveries before each run (`DiscoveryRegistrar` keeps scanning the container's `*Discovery` bindings). `Psr16DiscoverCacheDriver` caches scans in any PSR-16 cache
+- Post type and taxonomy attributes say what they register: each implements `ConfiguresArguments`, a pure function that returns the registration arguments with its own contribution (`#[HasArchive('library')]` sets `has_archive`, `#[Labels]` merges into the generated labels), and the method attributes (`#[AdminCol]`, `#[RegisterMetaBoxCb]`, `#[MetaBoxCb]`, `#[MetaBoxSanitizeCb]`, `#[UpdateCountCallback]`) implement `ConfiguresArgumentsWithMethod`, the discovery handing them the method as a callback. The `Pollora\Attributes` namespace no longer reaches Laravel or a framework service, which an architecture test keeps: the first step towards `pollora/attributes`, a package to declare attributes without the framework
+- The attributes live in their own package, [`pollora/attributes`](https://github.com/Pollora/attributes), which the framework requires: every attribute and its contracts, with the value types they take (`Every`, `Interval`, `Access`, `Control`, `BindingFieldType`). Class names do not change. The framework keeps the two deprecated bases, `PostTypeAttribute` and `TaxonomyAttribute`, and the code that reads the attributes
+- REST routes live in their own package, [`pollora/wp-rest`](https://github.com/Pollora/wp-rest), which the framework requires: `WpRestDiscovery`, the route registrar, the permissions (`IsLoggedIn`, `IsAdmin`, `Can`) and `Pollora\Support\WpGlobals`. Class names do not change. The framework keeps `WpRestAttributeServiceProvider`
+- WP-CLI commands live in their own package, [`pollora/wp-cli`](https://github.com/Pollora/wp-cli), which the framework requires: `WpCliDiscovery`, `WpCliService`, the WP-CLI adapters and `WordpressCommand`. Class names do not change. The framework keeps `WpCliServiceProvider` and `pollora:make:wp-cli`
+- Scheduling lives in its own package, [`pollora/schedule`](https://github.com/Pollora/schedule), which the framework requires: one engine behind `#[Schedule]` and a fluent API (`Schedule::event()`, `Schedule::once()`, `cancel()`, `next()`, `events()`), WP-Cron as the default driver, and `Schedule::fake()` for tests. `ScheduleDiscovery` keeps its class name and moves there, with the same hook names and schedule keys. The framework gives the engine its container, so the classes passed to `call()` get their dependencies, and keeps the bridge to Laravel's scheduler (`use_laravel_scheduler`)
+- `config/schedule.php` (publishable with the `pollora-schedule` tag) chooses the scheduling driver, `wp-cron` (the default) or `action-scheduler`, by `POLLORA_SCHEDULE_DRIVER`, and lists the hooks that stay on WP-Cron whatever the driver. The `cron` option now indexes every driver's events, so `wp_next_scheduled()`, Site Health and WP Crontrol see the ones Action Scheduler keeps, and WP-Cron never runs them. Changing the driver moves nothing by itself
+- `pollora:schedule:list` shows the scheduled events with their type, frequency and driver (`--type`, `--driver`, `--site`, `--network`, `--json`), read from the `cron` option with no query to a driver; an event on WP-Cron on purpose reads `(protected)` or `(fallback)`, one a former driver keeps reads `(to migrate)`. `pollora:schedule:migrate` moves the pending events from one driver to another (`--from`, `--to`, `--dry-run`), in both directions and safe to run again
+- `pollora:doctor`, Site Health and an administration notice report a driver that does not exist or is not available (events stay on WP-Cron), and the events a former driver still keeps, with the command that moves them
+- The `laravel` scheduling driver (`POLLORA_SCHEDULE_DRIVER=laravel`): every event is a job delayed until it is due, in the application's queue (`database`, Redis or Horizon, SQS), and a worker runs it. There is no table of Pollora's own and no `schedule:run`: the `cron` option is the record of what is scheduled, and cancelling an event takes its entry out of it. A job runs only if the entry still exists with its identifier and time, so a cancelled, moved or duplicated job stops; a recurring event queues its next occurrence once it ran or failed for good, never in the past. `config/schedule.php` gains `laravel.connection`, `queue`, `max_delay` (a job due later than the connection allows, 900 seconds on SQS, wakes and waits again) and `late_after`. On a multisite network the queue is shared, with one worker, and each job runs in the site that scheduled it
+- Lost jobs (a flushed Redis, a purged queue) are found again: at most every five minutes, as a request ends and under a lock, and with `php artisan pollora:schedule:recover` (`--network`, `--dry-run`), an event more than `late_after` seconds (600) past its time is queued again with the same identifier, so it never runs twice. `pollora:doctor` and Site Health report late events (no worker), a queue connection that is `sync` or `null`, and a scheduled event that failed for good in the last week
+
+### Changed
+- Requires Laravel 13.36: `illuminate/*` `^13.35` → `^13.36`. The framework version tracks Laravel's, so this release is v13.36.0
+- `DiscoveryEngine`, `DiscoveryCacheManager`, `InstancePool` and `ReflectionCache` take their dependencies instead of reaching for `app()` and `config()`; reading `structure-discoverer.cache` moved to `LaravelDiscoveryCacheManagerFactory`. `DiscoveryRegistrar::registerFromContainer()` is now `registerInto()`. These are infrastructure classes, outside the public contracts (`Pollora\Discovery\Domain\*`), which do not change
+- One `DiscoveryCacheManager` is shared by the engine and the `pollora:doctor` discovery cache check, which built its own
+- The discovery benchmarks measure memory as PHP allocates it, from a reset peak: they read 2 MB whatever the load
+- `#[WpRestRoute\Method]` no longer registers its route: `WpRestRouteRegistrar`, in the WpRest module, does, with the same arguments, permissions and request injection. Default slugs, meta keys and command names come from `Pollora\Attributes\Support\Naming`, which gives the same results as `Str::kebab()` and `Str::snake()`; `Pollora\Support\Slug` uses it
+- The WpRest module no longer reaches for Laravel's application, the first step towards `pollora/wp-rest`: custom `HandlesAttributes` attributes receive the PSR-11 container given to `WpRestDiscovery`, and `WpRestAttributableWrapper` takes the controller instance instead of building it. An architecture test keeps the module to the attributes, discovery, PSR contracts and WordPress
+- The WpCli module no longer reaches for Laravel's application, the first step towards `pollora/wp-cli`: `WpCliDiscovery` builds commands through the discovery's instance pool instead of `resolve()`. Its service provider and `pollora:make:wp-cli` stay in the framework; an architecture test keeps the rest to the attributes, discovery, PSR logging and WP-CLI
+- `#[Schedule]`, which runs on WP-Cron alone, no longer reaches for Laravel, the first step towards `pollora/schedule`: `ScheduleDiscovery` derives default hook names itself, with the same results as `Str::snake()` (a parity test keeps them), so hooks already scheduled keep their name. The bridge to Laravel's scheduler (`use_laravel_scheduler`) stays in the framework
+
+- Asynchronous actions queue through the WP-Cron and Action Scheduler drivers of `pollora/schedule`, with `pollora/hook` 2.0: `->async()`, `#[Async]`, `Async::fake()`, `via()` and their options do not change, and work already queued runs once the upgrade is in. `Async\Drivers\WpCronDriver` and `ActionSchedulerDriver` are gone with no alias; the framework never used them, and the `queue` driver is unchanged
+
+### Deprecated
+- `DiscoveryCacheInterface` and `LaravelDiscoveryCache`, removed in 15.0: nothing reads this cache, scans are cached by the Spatie driver given to `DiscoveryCacheManager`
+- `Pollora\Attributes\PostType\PostTypeAttribute` and `Pollora\Attributes\Taxonomy\TaxonomyAttribute`, removed in 15.0: no built-in attribute extends them any more; implement `ConfiguresArguments`
+
+### Removed
+- **The Laravel scheduling bridge, with no replacement under the same name**: `wordpress.use_laravel_scheduler`, `Pollora\Schedule\Scheduler`, `SchedulerInterface`, `Events\*` (`AbstractEvent`, `SingleEvent`, `RecurringEvent`), `Jobs\JobDispatcher`, `RegisterSchedulerFiltersUseCase`, the `pre_schedule_event`, `pre_option_cron` and related filters it installed, and the `wp_events` table. Setting the option no longer does anything. To keep WP-Cron events on Laravel's queue, set `POLLORA_SCHEDULE_DRIVER=laravel` and run a worker (`php artisan queue:work`) in place of `schedule:run`; events that sat in `wp_events` are not moved (let `#[Schedule]` and your own code schedule them again, then drop the table: `DROP TABLE wp_events`, with the prefix of your database connection). `pollora:doctor` no longer reads the option
+- `handle()` on `#[Action]`, `#[Filter]`, `#[WpRestRoute]` and `#[WpRestRoute\Method]`, and `Hook` no longer implements `HandlesAttributes`: `HookDiscovery` and `WpRestDiscovery` register hooks and routes themselves. Only the bug below reached `Action::handle()`, and `WpRestRoute::handle()` wrote to a read-only wrapper
+
+### Fixed
+- A queued asynchronous action runs even when the request that runs it registers no hook: the framework listens to `pollora/async/run` at boot instead of waiting for an `Action` to be built. Before, a handler registered only in the request that queued it was never run, and Action Scheduler marked it failed ("no callbacks are registered")
+- `wp_get_environment_type()` follows `APP_ENV` instead of reading `production` on every site (#443): `local` gives `local`, `dev`, `development` and `testing` give `development`, `staging` gives `staging`, anything else `production`. A `WP_ENVIRONMENT_TYPE` environment variable, a `wordpress.constants.WP_ENVIRONMENT_TYPE` value or a constant defined earlier still wins. WordPress 7.1 turns pings (incoming and outgoing pingbacks and trackbacks) off outside `production`, so they are off on a site with `APP_ENV=local` or `staging`: `add_filter('wp_should_disable_pings_for_environment', '__return_false')` brings them back
+- `pollora:doctor` no longer counts as overdue WP-Cron events the ones Action Scheduler keeps: the `cron` option indexes them too, with a `driver` key
+- A public `#[Action]` or `#[Filter]` method in a `#[PostType]` or `#[Taxonomy]` class is registered once: the post type and taxonomy discoveries also handed it to `Action::handle()`, which registered it a second time on the discovery's configuration object, so the page firing that hook failed with `call_user_func(): Argument #1 ($callback) must be a valid callback, class Pollora\PostType\Infrastructure\Services\PostTypeConfiguration does not have a method …`
+- A REST controller declared with `#[WpRestRoute]` gets its constructor dependencies: it is resolved through the discovery's instance pool (Laravel's container in a Pollora application) instead of `new` with no argument, so a controller with a required dependency answered every request with `Trying to invoke non static method … without an object`
## [v13.35.6](https://github.com/Pollora/framework/compare/v13.35.5...v13.35.6) - 2026-10-12
diff --git a/README.md b/README.md
index d6883d3f..db6588c8 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ composer create-project pollora/pollora my-project
Requirements:
- PHP 8.4+ for a new project (the skeleton's lock file ships Symfony 8; this package alone accepts PHP 8.3)
-- Laravel 13.35 (Pollora's version numbers follow the Laravel release it is built on)
+- Laravel 13.36 (Pollora's version numbers follow the Laravel release it is built on)
- WordPress 7.1+
## Quick start
diff --git a/composer.json b/composer.json
index c7662e94..1ab45cb6 100644
--- a/composer.json
+++ b/composer.json
@@ -24,16 +24,21 @@
"require": {
"php": "^8.3",
"pollora/helper-overrider": "^1.2",
- "illuminate/auth": "^13.35",
- "illuminate/support": "^13.35",
- "illuminate/database": "^13.35",
- "illuminate/routing": "^13.35",
- "illuminate/console": "^13.35",
+ "illuminate/auth": "^13.36",
+ "illuminate/support": "^13.36",
+ "illuminate/database": "^13.36",
+ "illuminate/routing": "^13.36",
+ "illuminate/console": "^13.36",
"pollora/abilities": "^1.0",
"pollora/ajax": "^1.1",
+ "pollora/attributes": "^0.1 || ^1.0",
+ "pollora/discovery": "^0.1 || ^1.0",
"pollora/entity": "^1.2",
- "pollora/hook": "^1.5",
+ "pollora/hook": "^2.0.1",
"pollora/option": "^1.1",
+ "pollora/schedule": "^1.0",
+ "pollora/wp-cli": "^0.1 || ^1.0",
+ "pollora/wp-rest": "^0.1 || ^1.0",
"watson/rememberable": "^7.1",
"cweagans/composer-patches": "^2.0",
"pollora/colt": "^10.0.1",
@@ -44,8 +49,8 @@
"coduo/php-humanizer": "5.0.3",
"nwidart/laravel-modules": "^13.0",
"symfony/process": "^7.4 || ^8.0",
- "illuminate/config": "^13.35",
- "laravel/prompts": "^0.3.24"
+ "illuminate/config": "^13.36",
+ "laravel/prompts": "^0.3.25"
},
"autoload": {
"psr-4": {
@@ -108,8 +113,8 @@
"brain/monkey": "^2.7",
"mockery/mockery": "^1.6.15",
"wp-cli/wp-cli": "^2.12",
- "larastan/larastan": "^3.12",
- "orchestra/testbench": "^11.1.0"
+ "larastan/larastan": "^3.13",
+ "orchestra/testbench": "^11.3.0"
},
"autoload-dev": {
"psr-4": {
diff --git a/config/schedule.php b/config/schedule.php
new file mode 100644
index 00000000..b59c9175
--- /dev/null
+++ b/config/schedule.php
@@ -0,0 +1,76 @@
+ env('POLLORA_SCHEDULE_DRIVER', 'wp-cron'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | The "laravel" driver
+ |--------------------------------------------------------------------------
+ |
+ | connection: the queue connection of the jobs; null is the application's
+ | default. On a multisite network every site uses the same one, with a
+ | single worker.
+ |
+ | queue: the queue they wait in; null is the connection's default.
+ |
+ | max_delay: the longest delay, in seconds, the connection accepts. A job
+ | due later waits again when it wakes. null: 900 for SQS, none otherwise.
+ |
+ | late_after: an event this many seconds past its time is queued again
+ | (same identifier, so it never runs twice) when its job was lost.
+ | Checked every five minutes, or with php artisan pollora:schedule:recover.
+ |
+ */
+
+ 'laravel' => [
+ 'connection' => env('POLLORA_SCHEDULE_QUEUE_CONNECTION'),
+ 'queue' => env('POLLORA_SCHEDULE_QUEUE'),
+ 'max_delay' => env('POLLORA_SCHEDULE_MAX_DELAY'),
+ 'late_after' => (int) env('POLLORA_SCHEDULE_LATE_AFTER', 600),
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Hooks that stay on WP-Cron
+ |--------------------------------------------------------------------------
+ |
+ | Action Scheduler's own queue runner always does: handed to Action
+ | Scheduler, it would never run. Add hooks that must run by WP-Cron
+ | whatever the driver. The pollora/schedule/protected_hooks filter extends
+ | the list too.
+ |
+ */
+
+ 'protected_hooks' => [],
+
+];
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index f75c76db..8334026c 100755
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -18,24 +18,6 @@ parameters:
count: 1
path: src/Asset/Infrastructure/Services/ViteManager.php
- -
- message: '#^Access to an undefined property Pollora\\Attributes\\Attributable\:\:\$classPermission\.$#'
- identifier: property.notFound
- count: 1
- path: src/Attributes/WpRestRoute/Method.php
-
- -
- message: '#^Access to an undefined property Pollora\\Attributes\\Attributable\:\:\$namespace\.$#'
- identifier: property.notFound
- count: 1
- path: src/Attributes/WpRestRoute/Method.php
-
- -
- message: '#^Access to an undefined property Pollora\\Attributes\\Attributable\:\:\$route\.$#'
- identifier: property.notFound
- count: 2
- path: src/Attributes/WpRestRoute/Method.php
-
-
message: '#^Constant AUTH_COOKIE not found\.$#'
identifier: constant.notFound
@@ -228,8 +210,3 @@ parameters:
count: 1
path: src/View/Application/UseCases/MakeTemplateIncludableUseCase.php
- -
- message: '#^Constant WP_CLI_VERSION not found\.$#'
- identifier: constant.notFound
- count: 1
- path: src/WpCli/Infrastructure/Adapters/WpCliAdapter.php
diff --git a/src/Attributes/Ability.php b/src/Attributes/Ability.php
deleted file mode 100644
index 5d8dcc35..00000000
--- a/src/Attributes/Ability.php
+++ /dev/null
@@ -1,60 +0,0 @@
-get(ActionService::class);
- if (! $actionService) {
- return;
- }
-
- // #[Action] targets methods only
- if (! $context instanceof ReflectionMethod) {
- return;
- }
-
- // Asynchronously when the method or its class carries #[Async]
- $logger = $this->optional($serviceLocator, LoggerInterface::class);
- $failures = $this->optional($serviceLocator, AsyncDeclarationFailures::class);
-
- (new AsyncAttributeRegistrar($actionService, $logger, $failures))->register(
- $attribute->hook,
- $instance,
- $context,
- $attribute->priority,
- $context->getNumberOfParameters()
- );
- }
-
- /**
- * A service the service locator may not provide: the logger, the record of ignored #[Async].
- *
- * @template T of object
- *
- * @param class-string $class
- * @return T|null
- */
- private function optional(object $serviceLocator, string $class): ?object
- {
- try {
- $service = $serviceLocator->get($class);
- } catch (\Throwable) {
- return null;
- }
-
- return $service instanceof $class ? $service : null;
- }
-}
diff --git a/src/Attributes/Ajax.php b/src/Attributes/Ajax.php
deleted file mode 100644
index 5dd17909..00000000
--- a/src/Attributes/Ajax.php
+++ /dev/null
@@ -1,40 +0,0 @@
-async(): #[Action] keeps the hook and
- * the priority, #[Async] carries the deferred-execution options.
- *
- * #[Action('save_post_event')]
- * #[Async(delay: 60, unique: true, tries: 3)]
- * public function syncToCrm(int $postId): void {}
- *
- * On a class, it applies to every #[Action] method; an #[Async] on a method
- * replaces the class options for that method. With several #[Action] on a
- * method, it applies to all of them, except the hooks listed in $except.
- */
-#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
-final readonly class Async
-{
- /**
- * @param int|\DateInterval|null $delay Minimum delay before execution, in seconds
- * @param string|null $via Driver: queue, action-scheduler, wp-cron, sync
- * @param string|null $onQueue Queue name, the group with Action Scheduler
- * @param bool|int $unique Merge identical triggers until the first one runs; an integer sets how long the lock lasts, in seconds
- * @param int|null $tries Attempts when the handler throws
- * @param int|list|null $backoff Seconds before each retry
- * @param bool|null $asUser Run as the user who fired the hook
- * @param string|null $capture Public method of the class recording values at trigger time
- * @param string|null $when Public method of the class deciding, at trigger time, whether to queue
- * @param bool $keepMissing Run with null in place of a referenced object deleted in the meantime
- * @param string|list $except Hooks of the method that stay synchronous
- */
- public function __construct(
- public int|\DateInterval|null $delay = null,
- public ?string $via = null,
- public ?string $onQueue = null,
- public bool|int $unique = false,
- public ?int $tries = null,
- public int|array|null $backoff = null,
- public ?bool $asUser = null,
- public ?string $capture = null,
- public ?string $when = null,
- public bool $keepMissing = false,
- public string|array $except = [],
- ) {}
-}
diff --git a/src/Attributes/Attributable.php b/src/Attributes/Attributable.php
deleted file mode 100755
index 942df62d..00000000
--- a/src/Attributes/Attributable.php
+++ /dev/null
@@ -1,19 +0,0 @@
-|Permission|null $classPermission
- * @property string $namespace
- * @property string $route
- */
-interface Attributable {}
diff --git a/src/Attributes/BlockBinding.php b/src/Attributes/BlockBinding.php
deleted file mode 100644
index e9da9805..00000000
--- a/src/Attributes/BlockBinding.php
+++ /dev/null
@@ -1,50 +0,0 @@
- */
- public array $usesContext;
-
- /** @var list */
- public array $postTypes;
-
- /**
- * @param string $name Source name, `namespace/name` in lowercase, as WordPress requires
- * @param string|null $label Label shown in the editor. Defaults to the class name, headlined
- * @param list $usesContext Block context the source reads
- * @param string|list $postTypes Post types the source answers for; empty for all
- */
- public function __construct(
- public string $name,
- public ?string $label = null,
- array $usesContext = ['postId', 'postType'],
- string|array $postTypes = [],
- ) {
- $this->usesContext = array_values($usesContext);
- $this->postTypes = array_values((array) $postTypes);
- }
-}
diff --git a/src/Attributes/BlockBinding/BindingField.php b/src/Attributes/BlockBinding/BindingField.php
deleted file mode 100644
index 4d8f436f..00000000
--- a/src/Attributes/BlockBinding/BindingField.php
+++ /dev/null
@@ -1,33 +0,0 @@
-get(FilterService::class);
- if (! $filterService) {
- return;
- }
-
- $filterService->add(
- $attribute->hook,
- [$instance, $context->getName()],
- $attribute->priority,
- $context->getNumberOfParameters()
- );
- }
-}
diff --git a/src/Attributes/Hook.php b/src/Attributes/Hook.php
deleted file mode 100755
index 511667ec..00000000
--- a/src/Attributes/Hook.php
+++ /dev/null
@@ -1,27 +0,0 @@
- $rules Laravel validation rules, checked on writes from PHP and REST
- * @param bool $single On an `array` property, false stores one row per item instead of one serialized array
- * @param string|null $items On an `array` property, the item type: `'string'`, `'int'`, `'float'`, `'bool'` or a class. Defaults to the `@var list<…>` docblock
- * @param Control|null $control The input a UI driver should build. Defaults to one derived from the type
- * @param string|null $group The group of fields a UI driver puts the meta in
- * @param array $hints Options passed as they are to UI drivers, by driver: `['acf' => ['wrapper' => ['width' => 50]]]`
- * @param bool $media On an `int` property, the value is an attachment ID: a block binding gives its URL, alt text or caption
- * @param bool $public The value may be shown to anyone: required for a user meta to be read by the `pollora/author-meta` block binding
- */
- public function __construct(
- public ?string $key = null,
- public bool $showInRest = false,
- public ?string $label = null,
- public ?string $description = null,
- public string|array|null $sanitize = null,
- public ?string $capability = null,
- public bool $revisions = false,
- public array $rules = [],
- public bool $single = true,
- public ?string $items = null,
- public ?Control $control = null,
- public ?string $group = null,
- public array $hints = [],
- public bool $media = false,
- public bool $public = false,
- ) {}
-
- /**
- * The key the meta is stored under for the given property.
- */
- public function resolveKey(string $property): string
- {
- return $this->key ?? Str::snake($property);
- }
-}
diff --git a/src/Attributes/ModifyRole.php b/src/Attributes/ModifyRole.php
deleted file mode 100644
index accf608b..00000000
--- a/src/Attributes/ModifyRole.php
+++ /dev/null
@@ -1,34 +0,0 @@
- */
- public array $postTypes;
-
- /**
- * @param string|list $postTypes The post type slugs the meta belong to
- */
- public function __construct(string|array $postTypes)
- {
- $this->postTypes = array_values((array) $postTypes);
- }
-}
diff --git a/src/Attributes/PostType.php b/src/Attributes/PostType.php
deleted file mode 100644
index 80ba791f..00000000
--- a/src/Attributes/PostType.php
+++ /dev/null
@@ -1,56 +0,0 @@
-slug ?? substr(Str::kebab(class_basename($className)), 0, 20);
- }
-}
diff --git a/src/Attributes/PostType/AdminCol.php b/src/Attributes/PostType/AdminCol.php
deleted file mode 100755
index 645c882e..00000000
--- a/src/Attributes/PostType/AdminCol.php
+++ /dev/null
@@ -1,140 +0,0 @@
-getArg('admin_cols', []);
-
- // Build column configuration based on extended-cpts options
- $columnConfig = [
- 'title' => $this->title,
- ];
-
- // Add custom function callback if this is a method-level attribute
- if ($context instanceof ReflectionMethod) {
- $columnConfig['function'] = [new $context->class, $context->getName()];
- }
-
- // Add optional configuration based on extended-cpts features
- if ($this->titleIcon !== null) {
- $columnConfig['title_icon'] = $this->titleIcon;
- }
-
- if ($this->dateFormat !== null) {
- $columnConfig['date_format'] = $this->dateFormat;
- }
-
- if ($this->link !== null) {
- $columnConfig['link'] = $this->link;
- }
-
- if ($this->cap !== null) {
- $columnConfig['cap'] = $this->cap;
- }
-
- if ($this->postCap !== null) {
- $columnConfig['post_cap'] = $this->postCap;
- }
-
- if ($this->default !== null) {
- $columnConfig['default'] = $this->default;
- }
-
- if ($this->metaKey !== null) {
- $columnConfig['meta_key'] = $this->metaKey;
- }
-
- if ($this->taxonomy !== null) {
- $columnConfig['taxonomy'] = $this->taxonomy;
- }
-
- if ($this->featuredImage !== null) {
- $columnConfig['featured_image'] = $this->featuredImage;
- }
-
- if ($this->postField !== null) {
- $columnConfig['post_field'] = $this->postField;
- }
-
- if ($this->width !== null) {
- $columnConfig['width'] = $this->width;
- }
-
- // Handle sortable configuration
- if ($this->sortable !== false) {
- $columnConfig['sortable'] = $this->sortable;
- }
-
- // Add the column to the admin_cols array
- $adminCols[$this->key] = $columnConfig;
- $instance->setArg('admin_cols', $adminCols);
- }
-}
diff --git a/src/Attributes/PostType/AdminCols.php b/src/Attributes/PostType/AdminCols.php
deleted file mode 100755
index ac92f1ba..00000000
--- a/src/Attributes/PostType/AdminCols.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('admin_cols', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/AdminFilters.php b/src/Attributes/PostType/AdminFilters.php
deleted file mode 100755
index 6c6de4d1..00000000
--- a/src/Attributes/PostType/AdminFilters.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('admin_filters', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/Archive.php b/src/Attributes/PostType/Archive.php
deleted file mode 100755
index d65294de..00000000
--- a/src/Attributes/PostType/Archive.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('archive', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/BlockEditor.php b/src/Attributes/PostType/BlockEditor.php
deleted file mode 100755
index 145fdf1b..00000000
--- a/src/Attributes/PostType/BlockEditor.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('show_in_rest', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/CanExport.php b/src/Attributes/PostType/CanExport.php
deleted file mode 100755
index 73199a2c..00000000
--- a/src/Attributes/PostType/CanExport.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('can_export', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/Capabilities.php b/src/Attributes/PostType/Capabilities.php
deleted file mode 100755
index 7fc3e65d..00000000
--- a/src/Attributes/PostType/Capabilities.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('capabilities', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/CapabilityType.php b/src/Attributes/PostType/CapabilityType.php
deleted file mode 100755
index 886630d6..00000000
--- a/src/Attributes/PostType/CapabilityType.php
+++ /dev/null
@@ -1,44 +0,0 @@
-setArg('capability_type', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/Chronological.php b/src/Attributes/PostType/Chronological.php
deleted file mode 100755
index 10065f3b..00000000
--- a/src/Attributes/PostType/Chronological.php
+++ /dev/null
@@ -1,44 +0,0 @@
-value) {
- // Set default ordering to date, descending
- $postType->setArg('orderby', 'date');
- $postType->setArg('order', 'DESC');
- }
- }
-}
diff --git a/src/Attributes/PostType/DashboardActivity.php b/src/Attributes/PostType/DashboardActivity.php
deleted file mode 100755
index 97ae5c52..00000000
--- a/src/Attributes/PostType/DashboardActivity.php
+++ /dev/null
@@ -1,40 +0,0 @@
-setArg('dashboard_activity', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/DashboardGlance.php b/src/Attributes/PostType/DashboardGlance.php
deleted file mode 100755
index 99a0e08f..00000000
--- a/src/Attributes/PostType/DashboardGlance.php
+++ /dev/null
@@ -1,38 +0,0 @@
-setArg('dashboard_glance', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/DeleteWithUser.php b/src/Attributes/PostType/DeleteWithUser.php
deleted file mode 100755
index b762aeed..00000000
--- a/src/Attributes/PostType/DeleteWithUser.php
+++ /dev/null
@@ -1,40 +0,0 @@
-setArg('delete_with_user', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/Description.php b/src/Attributes/PostType/Description.php
deleted file mode 100755
index 7ba770eb..00000000
--- a/src/Attributes/PostType/Description.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('description', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/ExcludeFromSearch.php b/src/Attributes/PostType/ExcludeFromSearch.php
deleted file mode 100755
index 69e75e60..00000000
--- a/src/Attributes/PostType/ExcludeFromSearch.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('exclude_from_search', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/FeaturedImage.php b/src/Attributes/PostType/FeaturedImage.php
deleted file mode 100755
index 8e8c8daa..00000000
--- a/src/Attributes/PostType/FeaturedImage.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('featured_image', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/HasArchive.php b/src/Attributes/PostType/HasArchive.php
deleted file mode 100755
index cc2c7786..00000000
--- a/src/Attributes/PostType/HasArchive.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('has_archive', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/Hierarchical.php b/src/Attributes/PostType/Hierarchical.php
deleted file mode 100755
index f7e2610e..00000000
--- a/src/Attributes/PostType/Hierarchical.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('hierarchical', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/Label.php b/src/Attributes/PostType/Label.php
deleted file mode 100755
index 33720b71..00000000
--- a/src/Attributes/PostType/Label.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('label', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/Labels.php b/src/Attributes/PostType/Labels.php
deleted file mode 100755
index 026fd71b..00000000
--- a/src/Attributes/PostType/Labels.php
+++ /dev/null
@@ -1,100 +0,0 @@
- */
- private readonly array $labels;
-
- public function __construct(
- ?string $name = null,
- ?string $singularName = null,
- ?string $menuName = null,
- ?string $allItems = null,
- ?string $addNew = null,
- ?string $addNewItem = null,
- ?string $editItem = null,
- ?string $newItem = null,
- ?string $viewItem = null,
- ?string $viewItems = null,
- ?string $searchItems = null,
- ?string $notFound = null,
- ?string $notFoundInTrash = null,
- ?string $parentItemColon = null,
- ?string $archives = null,
- ?string $attributes = null,
- ?string $insertIntoItem = null,
- ?string $uploadedToThisItem = null,
- ?string $featuredImage = null,
- ?string $setFeaturedImage = null,
- ?string $removeFeaturedImage = null,
- ?string $useFeaturedImage = null,
- ?string $filterItemsList = null,
- ?string $itemsListNavigation = null,
- ?string $itemsList = null,
- ?string $itemPublished = null,
- ?string $itemUpdated = null,
- ?string $itemScheduled = null,
- ?string $itemReverted = null,
- ) {
- $this->labels = array_filter([
- 'name' => $name,
- 'singular_name' => $singularName,
- 'menu_name' => $menuName,
- 'all_items' => $allItems,
- 'add_new' => $addNew,
- 'add_new_item' => $addNewItem,
- 'edit_item' => $editItem,
- 'new_item' => $newItem,
- 'view_item' => $viewItem,
- 'view_items' => $viewItems,
- 'search_items' => $searchItems,
- 'not_found' => $notFound,
- 'not_found_in_trash' => $notFoundInTrash,
- 'parent_item_colon' => $parentItemColon,
- 'archives' => $archives,
- 'attributes' => $attributes,
- 'insert_into_item' => $insertIntoItem,
- 'uploaded_to_this_item' => $uploadedToThisItem,
- 'featured_image' => $featuredImage,
- 'set_featured_image' => $setFeaturedImage,
- 'remove_featured_image' => $removeFeaturedImage,
- 'use_featured_image' => $useFeaturedImage,
- 'filter_items_list' => $filterItemsList,
- 'items_list_navigation' => $itemsListNavigation,
- 'items_list' => $itemsList,
- 'item_published' => $itemPublished,
- 'item_updated' => $itemUpdated,
- 'item_scheduled' => $itemScheduled,
- 'item_reverted_to_draft' => $itemReverted,
- ], fn (?string $v): bool => $v !== null);
- }
-
- protected function configure(PostTypeAttributeInterface $postType): void
- {
- $existing = $postType->getArg('labels', []);
- $postType->setArg('labels', array_merge($existing, $this->labels));
- }
-}
diff --git a/src/Attributes/PostType/MapMetaCap.php b/src/Attributes/PostType/MapMetaCap.php
deleted file mode 100755
index 1dea4737..00000000
--- a/src/Attributes/PostType/MapMetaCap.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('map_meta_cap', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/MenuIcon.php b/src/Attributes/PostType/MenuIcon.php
deleted file mode 100755
index 7450e6ea..00000000
--- a/src/Attributes/PostType/MenuIcon.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('menu_icon', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/MenuPosition.php b/src/Attributes/PostType/MenuPosition.php
deleted file mode 100755
index 41cb7b1c..00000000
--- a/src/Attributes/PostType/MenuPosition.php
+++ /dev/null
@@ -1,50 +0,0 @@
-setArg('menu_position', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/PostTypeAttribute.php b/src/Attributes/PostType/PostTypeAttribute.php
index 0ecb963f..ef612c31 100755
--- a/src/Attributes/PostType/PostTypeAttribute.php
+++ b/src/Attributes/PostType/PostTypeAttribute.php
@@ -5,6 +5,7 @@
namespace Pollora\Attributes\PostType;
use Pollora\Attributes\Attributable;
+use Pollora\Attributes\Contracts\ConfiguresArguments;
use Pollora\Attributes\Contracts\HandlesAttributes;
use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface;
use ReflectionClass;
@@ -16,7 +17,9 @@
* This attribute serves as the base for all post type configuration attributes.
* It provides common functionality for handling post type registration arguments.
*
- * @Attribute
+ * @deprecated since 13.36, removed in 15.0. Implement
+ * {@see ConfiguresArguments}, as the
+ * built-in attributes now do.
*/
abstract class PostTypeAttribute implements HandlesAttributes
{
diff --git a/src/Attributes/PostType/Priority.php b/src/Attributes/PostType/Priority.php
deleted file mode 100755
index a1176bb5..00000000
--- a/src/Attributes/PostType/Priority.php
+++ /dev/null
@@ -1,34 +0,0 @@
-setArg('priority', $this->priority);
- }
-}
diff --git a/src/Attributes/PostType/PublicPostType.php b/src/Attributes/PostType/PublicPostType.php
deleted file mode 100755
index fdcc04a6..00000000
--- a/src/Attributes/PostType/PublicPostType.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('public', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/PubliclyQueryable.php b/src/Attributes/PostType/PubliclyQueryable.php
deleted file mode 100755
index 1957ecbe..00000000
--- a/src/Attributes/PostType/PubliclyQueryable.php
+++ /dev/null
@@ -1,38 +0,0 @@
-setArg('publicly_queryable', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/QueryVar.php b/src/Attributes/PostType/QueryVar.php
deleted file mode 100755
index d70da40e..00000000
--- a/src/Attributes/PostType/QueryVar.php
+++ /dev/null
@@ -1,41 +0,0 @@
-setArg('query_var', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/QuickEdit.php b/src/Attributes/PostType/QuickEdit.php
deleted file mode 100755
index bbc2cc47..00000000
--- a/src/Attributes/PostType/QuickEdit.php
+++ /dev/null
@@ -1,40 +0,0 @@
-setArg('quick_edit', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/RegisterMetaBoxCb.php b/src/Attributes/PostType/RegisterMetaBoxCb.php
deleted file mode 100755
index 40e66c41..00000000
--- a/src/Attributes/PostType/RegisterMetaBoxCb.php
+++ /dev/null
@@ -1,42 +0,0 @@
-setArg('register_meta_box_cb', [new $context->class, $context->getName()]);
- }
-}
diff --git a/src/Attributes/PostType/RestBase.php b/src/Attributes/PostType/RestBase.php
deleted file mode 100755
index 0dff0967..00000000
--- a/src/Attributes/PostType/RestBase.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('rest_base', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/RestControllerClass.php b/src/Attributes/PostType/RestControllerClass.php
deleted file mode 100755
index ac8ba8a6..00000000
--- a/src/Attributes/PostType/RestControllerClass.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('rest_controller_class', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/RestNamespace.php b/src/Attributes/PostType/RestNamespace.php
deleted file mode 100755
index 77244233..00000000
--- a/src/Attributes/PostType/RestNamespace.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('rest_namespace', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/Rewrite.php b/src/Attributes/PostType/Rewrite.php
deleted file mode 100755
index 122e1c0d..00000000
--- a/src/Attributes/PostType/Rewrite.php
+++ /dev/null
@@ -1,41 +0,0 @@
-setArg('rewrite', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/ShowInAdminBar.php b/src/Attributes/PostType/ShowInAdminBar.php
deleted file mode 100755
index 99cf0690..00000000
--- a/src/Attributes/PostType/ShowInAdminBar.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('show_in_admin_bar', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/ShowInFeed.php b/src/Attributes/PostType/ShowInFeed.php
deleted file mode 100755
index b7cc0ce0..00000000
--- a/src/Attributes/PostType/ShowInFeed.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('show_in_feed', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/ShowInMenu.php b/src/Attributes/PostType/ShowInMenu.php
deleted file mode 100755
index fd2371fa..00000000
--- a/src/Attributes/PostType/ShowInMenu.php
+++ /dev/null
@@ -1,41 +0,0 @@
-setArg('show_in_menu', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/ShowInNavMenus.php b/src/Attributes/PostType/ShowInNavMenus.php
deleted file mode 100755
index c42b0f97..00000000
--- a/src/Attributes/PostType/ShowInNavMenus.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('show_in_nav_menus', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/ShowInRest.php b/src/Attributes/PostType/ShowInRest.php
deleted file mode 100755
index 81527a44..00000000
--- a/src/Attributes/PostType/ShowInRest.php
+++ /dev/null
@@ -1,40 +0,0 @@
-setArg('show_in_rest', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/ShowUI.php b/src/Attributes/PostType/ShowUI.php
deleted file mode 100755
index eda88522..00000000
--- a/src/Attributes/PostType/ShowUI.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('show_ui', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/SiteFilters.php b/src/Attributes/PostType/SiteFilters.php
deleted file mode 100755
index cc7ff9ab..00000000
--- a/src/Attributes/PostType/SiteFilters.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('site_filters', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/SiteSortables.php b/src/Attributes/PostType/SiteSortables.php
deleted file mode 100755
index 0462fa75..00000000
--- a/src/Attributes/PostType/SiteSortables.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('site_sortables', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/Supports.php b/src/Attributes/PostType/Supports.php
deleted file mode 100755
index 5ed03d98..00000000
--- a/src/Attributes/PostType/Supports.php
+++ /dev/null
@@ -1,38 +0,0 @@
- $features Array of features the post type supports
- */
- public function __construct(
- public readonly array $features = ['title', 'editor']
- ) {}
-
- /**
- * Configure the post type with the supports parameter.
- *
- * @param PostTypeAttributeInterface $postType The post type to configure
- */
- protected function configure(PostTypeAttributeInterface $postType): void
- {
- $postType->setArg('supports', $this->features);
- }
-}
diff --git a/src/Attributes/PostType/Taxonomies.php b/src/Attributes/PostType/Taxonomies.php
deleted file mode 100755
index ec1fe97a..00000000
--- a/src/Attributes/PostType/Taxonomies.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('taxonomies', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/Template.php b/src/Attributes/PostType/Template.php
deleted file mode 100755
index a4582bb8..00000000
--- a/src/Attributes/PostType/Template.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('template', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/TemplateLock.php b/src/Attributes/PostType/TemplateLock.php
deleted file mode 100755
index a06cb614..00000000
--- a/src/Attributes/PostType/TemplateLock.php
+++ /dev/null
@@ -1,41 +0,0 @@
-setArg('template_lock', $this->value);
- }
-}
diff --git a/src/Attributes/PostType/TitlePlaceholder.php b/src/Attributes/PostType/TitlePlaceholder.php
deleted file mode 100755
index 26418f29..00000000
--- a/src/Attributes/PostType/TitlePlaceholder.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('title_placeholder', $this->value);
- }
-}
diff --git a/src/Attributes/Role.php b/src/Attributes/Role.php
deleted file mode 100644
index f4909c2e..00000000
--- a/src/Attributes/Role.php
+++ /dev/null
@@ -1,42 +0,0 @@
-
- */
- public array $capabilities;
-
- public function __construct(string|BackedEnum ...$capabilities)
- {
- $this->capabilities = array_values($capabilities);
- }
-}
diff --git a/src/Attributes/Role/GrantsPostType.php b/src/Attributes/Role/GrantsPostType.php
deleted file mode 100644
index b16c96da..00000000
--- a/src/Attributes/Role/GrantsPostType.php
+++ /dev/null
@@ -1,27 +0,0 @@
-
- */
- public array $capabilities;
-
- public function __construct(string|BackedEnum ...$capabilities)
- {
- $this->capabilities = array_values($capabilities);
- }
-}
diff --git a/src/Attributes/Schedule.php b/src/Attributes/Schedule.php
deleted file mode 100755
index fc72a85d..00000000
--- a/src/Attributes/Schedule.php
+++ /dev/null
@@ -1,46 +0,0 @@
-|Every|Interval $recurrence The schedule recurrence definition
- * - string: WordPress built-in schedule ('hourly', 'daily', 'weekly', 'twicedaily')
- * - array: Custom schedule with 'interval' (seconds) and 'display' (name) keys
- * - Every: Enum for predefined intervals (Every::HOUR, Every::DAY, etc.)
- * - Interval: Custom interval instance with precise timing control
- * @param string|null $hook Optional custom hook name. If null, generated from class and method names
- * @param array $args Optional arguments to pass to the scheduled method
- */
- public function __construct(
- public readonly string|array|Every|Interval $recurrence,
- public readonly ?string $hook = null,
- public readonly array $args = []
- ) {
- // No validation here - will be handled by ScheduleDiscovery
- }
-}
diff --git a/src/Attributes/SkipDiscovery.php b/src/Attributes/SkipDiscovery.php
deleted file mode 100644
index fcb5704f..00000000
--- a/src/Attributes/SkipDiscovery.php
+++ /dev/null
@@ -1,31 +0,0 @@
- $except Discovery classes that should still process this class
- */
-#[Attribute(Attribute::TARGET_CLASS)]
-final readonly class SkipDiscovery
-{
- /**
- * @param array $except Discovery classes that should still process this class
- */
- public function __construct(public array $except = []) {}
-}
diff --git a/src/Attributes/Taxonomy.php b/src/Attributes/Taxonomy.php
deleted file mode 100644
index c301d052..00000000
--- a/src/Attributes/Taxonomy.php
+++ /dev/null
@@ -1,66 +0,0 @@
-|string|null $objectType The post types this taxonomy applies to. If null, defaults to ['post'].
- */
- public function __construct(
- public ?string $slug = null,
- public ?string $singular = null,
- public ?string $plural = null,
- public array|string|null $objectType = null,
- public ?string $textDomain = null
- ) {}
-
- /**
- * The slug WordPress registers the taxonomy under.
- *
- * The explicit slug when the attribute has one; otherwise the class name in
- * kebab-case, cut to 20 characters. Discovery registers with it and
- * `pollora:status` reports it, so the two cannot disagree.
- *
- * @param string $className The class carrying the attribute
- */
- public function resolveSlug(string $className): string
- {
- return $this->slug ?? substr(Str::kebab(class_basename($className)), 0, 20);
- }
-}
diff --git a/src/Attributes/Taxonomy/AllowHierarchy.php b/src/Attributes/Taxonomy/AllowHierarchy.php
deleted file mode 100755
index d9eff1ea..00000000
--- a/src/Attributes/Taxonomy/AllowHierarchy.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('allow_hierarchy', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/Args.php b/src/Attributes/Taxonomy/Args.php
deleted file mode 100755
index fc4889b2..00000000
--- a/src/Attributes/Taxonomy/Args.php
+++ /dev/null
@@ -1,36 +0,0 @@
-setArg('args', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/Capabilities.php b/src/Attributes/Taxonomy/Capabilities.php
deleted file mode 100644
index 36d87d9a..00000000
--- a/src/Attributes/Taxonomy/Capabilities.php
+++ /dev/null
@@ -1,45 +0,0 @@
-setArg('capabilities', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/CheckedOntop.php b/src/Attributes/Taxonomy/CheckedOntop.php
deleted file mode 100755
index bb8b092c..00000000
--- a/src/Attributes/Taxonomy/CheckedOntop.php
+++ /dev/null
@@ -1,37 +0,0 @@
-setArg('checked_ontop', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/DefaultTerm.php b/src/Attributes/Taxonomy/DefaultTerm.php
deleted file mode 100755
index a6fa0e3a..00000000
--- a/src/Attributes/Taxonomy/DefaultTerm.php
+++ /dev/null
@@ -1,36 +0,0 @@
-setArg('default_term', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/Description.php b/src/Attributes/Taxonomy/Description.php
deleted file mode 100755
index 6a9d91ca..00000000
--- a/src/Attributes/Taxonomy/Description.php
+++ /dev/null
@@ -1,36 +0,0 @@
-setArg('description', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/Exclusive.php b/src/Attributes/Taxonomy/Exclusive.php
deleted file mode 100755
index 5a14a75e..00000000
--- a/src/Attributes/Taxonomy/Exclusive.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('exclusive', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/Hierarchical.php b/src/Attributes/Taxonomy/Hierarchical.php
deleted file mode 100755
index 96b633e4..00000000
--- a/src/Attributes/Taxonomy/Hierarchical.php
+++ /dev/null
@@ -1,36 +0,0 @@
-setArg('hierarchical', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/Label.php b/src/Attributes/Taxonomy/Label.php
deleted file mode 100755
index 18d11a03..00000000
--- a/src/Attributes/Taxonomy/Label.php
+++ /dev/null
@@ -1,36 +0,0 @@
-setArg('label', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/Labels.php b/src/Attributes/Taxonomy/Labels.php
deleted file mode 100755
index 0f9173dc..00000000
--- a/src/Attributes/Taxonomy/Labels.php
+++ /dev/null
@@ -1,87 +0,0 @@
- */
- private readonly array $labels;
-
- public function __construct(
- ?string $name = null,
- ?string $singularName = null,
- ?string $menuName = null,
- ?string $allItems = null,
- ?string $editItem = null,
- ?string $viewItem = null,
- ?string $updateItem = null,
- ?string $addNewItem = null,
- ?string $newItemName = null,
- ?string $searchItems = null,
- ?string $popularItems = null,
- ?string $separateItemsWithCommas = null,
- ?string $addOrRemoveItems = null,
- ?string $chooseFromMostUsed = null,
- ?string $notFound = null,
- ?string $parentItem = null,
- ?string $parentItemColon = null,
- ?string $noTerms = null,
- ?string $filterByItem = null,
- ?string $itemsListNavigation = null,
- ?string $itemsList = null,
- ?string $backToItems = null,
- ) {
- $this->labels = array_filter([
- 'name' => $name,
- 'singular_name' => $singularName,
- 'menu_name' => $menuName,
- 'all_items' => $allItems,
- 'edit_item' => $editItem,
- 'view_item' => $viewItem,
- 'update_item' => $updateItem,
- 'add_new_item' => $addNewItem,
- 'new_item_name' => $newItemName,
- 'search_items' => $searchItems,
- 'popular_items' => $popularItems,
- 'separate_items_with_commas' => $separateItemsWithCommas,
- 'add_or_remove_items' => $addOrRemoveItems,
- 'choose_from_most_used' => $chooseFromMostUsed,
- 'not_found' => $notFound,
- 'parent_item' => $parentItem,
- 'parent_item_colon' => $parentItemColon,
- 'no_terms' => $noTerms,
- 'filter_by_item' => $filterByItem,
- 'items_list_navigation' => $itemsListNavigation,
- 'items_list' => $itemsList,
- 'back_to_items' => $backToItems,
- ], fn (?string $v): bool => $v !== null);
- }
-
- protected function configure(TaxonomyAttributeInterface $taxonomy): void
- {
- $existing = $taxonomy->getArg('labels', []);
- $taxonomy->setArg('labels', array_merge($existing, $this->labels));
- }
-}
diff --git a/src/Attributes/Taxonomy/MetaBoxCb.php b/src/Attributes/Taxonomy/MetaBoxCb.php
deleted file mode 100755
index 5eb310bd..00000000
--- a/src/Attributes/Taxonomy/MetaBoxCb.php
+++ /dev/null
@@ -1,40 +0,0 @@
-setArg('meta_box_cb', [new $context->class, $context->getName()]);
- }
-}
diff --git a/src/Attributes/Taxonomy/MetaBoxSanitizeCb.php b/src/Attributes/Taxonomy/MetaBoxSanitizeCb.php
deleted file mode 100755
index e24dcdcf..00000000
--- a/src/Attributes/Taxonomy/MetaBoxSanitizeCb.php
+++ /dev/null
@@ -1,40 +0,0 @@
-setArg('meta_box_sanitize_cb', [new $context->class, $context->getName()]);
- }
-}
diff --git a/src/Attributes/Taxonomy/ObjectType.php b/src/Attributes/Taxonomy/ObjectType.php
deleted file mode 100755
index c39a90e9..00000000
--- a/src/Attributes/Taxonomy/ObjectType.php
+++ /dev/null
@@ -1,36 +0,0 @@
- $value The post type(s) this taxonomy is associated with
- */
- public function __construct(
- public readonly string|array $value
- ) {}
-
- /**
- * Configure the taxonomy with the object_type parameter.
- *
- * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure
- */
- protected function configure(TaxonomyAttributeInterface $taxonomy): void
- {
- $taxonomy->setArg('object_type', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/Priority.php b/src/Attributes/Taxonomy/Priority.php
deleted file mode 100755
index fc9e3d44..00000000
--- a/src/Attributes/Taxonomy/Priority.php
+++ /dev/null
@@ -1,34 +0,0 @@
-setArg('priority', $this->priority);
- }
-}
diff --git a/src/Attributes/Taxonomy/PublicTaxonomy.php b/src/Attributes/Taxonomy/PublicTaxonomy.php
deleted file mode 100755
index b8205a15..00000000
--- a/src/Attributes/Taxonomy/PublicTaxonomy.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setArg('public', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/QueryVar.php b/src/Attributes/Taxonomy/QueryVar.php
deleted file mode 100755
index 61e3a168..00000000
--- a/src/Attributes/Taxonomy/QueryVar.php
+++ /dev/null
@@ -1,36 +0,0 @@
-setArg('query_var', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/RestBase.php b/src/Attributes/Taxonomy/RestBase.php
deleted file mode 100755
index 9eb7c2ab..00000000
--- a/src/Attributes/Taxonomy/RestBase.php
+++ /dev/null
@@ -1,36 +0,0 @@
-setArg('rest_base', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/RestControllerClass.php b/src/Attributes/Taxonomy/RestControllerClass.php
deleted file mode 100755
index e54a48c7..00000000
--- a/src/Attributes/Taxonomy/RestControllerClass.php
+++ /dev/null
@@ -1,36 +0,0 @@
-setArg('rest_controller_class', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/RestNamespace.php b/src/Attributes/Taxonomy/RestNamespace.php
deleted file mode 100755
index c2a28acd..00000000
--- a/src/Attributes/Taxonomy/RestNamespace.php
+++ /dev/null
@@ -1,36 +0,0 @@
-setArg('rest_namespace', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/Rewrite.php b/src/Attributes/Taxonomy/Rewrite.php
deleted file mode 100755
index ebd639d1..00000000
--- a/src/Attributes/Taxonomy/Rewrite.php
+++ /dev/null
@@ -1,36 +0,0 @@
-setArg('rewrite', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/ShowAdminColumn.php b/src/Attributes/Taxonomy/ShowAdminColumn.php
deleted file mode 100755
index eef42295..00000000
--- a/src/Attributes/Taxonomy/ShowAdminColumn.php
+++ /dev/null
@@ -1,36 +0,0 @@
-setArg('show_admin_column', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/ShowInMenu.php b/src/Attributes/Taxonomy/ShowInMenu.php
deleted file mode 100755
index 249546f6..00000000
--- a/src/Attributes/Taxonomy/ShowInMenu.php
+++ /dev/null
@@ -1,41 +0,0 @@
-setArg('show_in_menu', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/ShowInNavMenus.php b/src/Attributes/Taxonomy/ShowInNavMenus.php
deleted file mode 100755
index 9bf014cc..00000000
--- a/src/Attributes/Taxonomy/ShowInNavMenus.php
+++ /dev/null
@@ -1,36 +0,0 @@
-setArg('show_in_nav_menus', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/ShowInQuickEdit.php b/src/Attributes/Taxonomy/ShowInQuickEdit.php
deleted file mode 100755
index 8105d502..00000000
--- a/src/Attributes/Taxonomy/ShowInQuickEdit.php
+++ /dev/null
@@ -1,36 +0,0 @@
-setArg('show_in_quick_edit', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/ShowInRest.php b/src/Attributes/Taxonomy/ShowInRest.php
deleted file mode 100755
index ed6fe08d..00000000
--- a/src/Attributes/Taxonomy/ShowInRest.php
+++ /dev/null
@@ -1,36 +0,0 @@
-setArg('show_in_rest', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/ShowTagcloud.php b/src/Attributes/Taxonomy/ShowTagcloud.php
deleted file mode 100755
index 603d7d5a..00000000
--- a/src/Attributes/Taxonomy/ShowTagcloud.php
+++ /dev/null
@@ -1,36 +0,0 @@
-setArg('show_tagcloud', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/ShowUI.php b/src/Attributes/Taxonomy/ShowUI.php
deleted file mode 100755
index 6c0e346d..00000000
--- a/src/Attributes/Taxonomy/ShowUI.php
+++ /dev/null
@@ -1,36 +0,0 @@
-setArg('show_ui', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/Sort.php b/src/Attributes/Taxonomy/Sort.php
deleted file mode 100755
index f9902ada..00000000
--- a/src/Attributes/Taxonomy/Sort.php
+++ /dev/null
@@ -1,36 +0,0 @@
-setArg('sort', $this->value);
- }
-}
diff --git a/src/Attributes/Taxonomy/TaxonomyAttribute.php b/src/Attributes/Taxonomy/TaxonomyAttribute.php
index 292210a3..9ca188dc 100755
--- a/src/Attributes/Taxonomy/TaxonomyAttribute.php
+++ b/src/Attributes/Taxonomy/TaxonomyAttribute.php
@@ -5,6 +5,7 @@
namespace Pollora\Attributes\Taxonomy;
use Pollora\Attributes\Attributable;
+use Pollora\Attributes\Contracts\ConfiguresArguments;
use Pollora\Attributes\Contracts\HandlesAttributes;
use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface;
use ReflectionClass;
@@ -16,7 +17,9 @@
* This attribute serves as the base for all taxonomy configuration attributes.
* It provides common functionality for handling taxonomy registration arguments.
*
- * @Attribute
+ * @deprecated since 13.36, removed in 15.0. Implement
+ * {@see ConfiguresArguments}, as the
+ * built-in attributes now do.
*/
abstract class TaxonomyAttribute implements HandlesAttributes
{
diff --git a/src/Attributes/Taxonomy/UpdateCountCallback.php b/src/Attributes/Taxonomy/UpdateCountCallback.php
deleted file mode 100755
index 218a2465..00000000
--- a/src/Attributes/Taxonomy/UpdateCountCallback.php
+++ /dev/null
@@ -1,40 +0,0 @@
-setArg('update_count_callback', [new $context->class, $context->getName()]);
- }
-}
diff --git a/src/Attributes/TermMeta.php b/src/Attributes/TermMeta.php
deleted file mode 100644
index 8bdc3d09..00000000
--- a/src/Attributes/TermMeta.php
+++ /dev/null
@@ -1,37 +0,0 @@
- */
- public array $taxonomies;
-
- /**
- * @param string|list $taxonomies The taxonomy slugs the meta belong to
- */
- public function __construct(string|array $taxonomies)
- {
- $this->taxonomies = array_values((array) $taxonomies);
- }
-}
diff --git a/src/Attributes/UserMeta.php b/src/Attributes/UserMeta.php
deleted file mode 100644
index 25a70858..00000000
--- a/src/Attributes/UserMeta.php
+++ /dev/null
@@ -1,25 +0,0 @@
-newsletterOptIn;
- *
- * @experimental The API may still change before it is declared stable.
- */
-#[Attribute(Attribute::TARGET_CLASS)]
-final readonly class UserMeta {}
diff --git a/src/Attributes/WpCli.php b/src/Attributes/WpCli.php
deleted file mode 100644
index 7248b637..00000000
--- a/src/Attributes/WpCli.php
+++ /dev/null
@@ -1,44 +0,0 @@
-commandName ?? Slug::fromClassName($className);
- }
-}
diff --git a/src/Attributes/WpCli/AfterInvoke.php b/src/Attributes/WpCli/AfterInvoke.php
deleted file mode 100644
index aaa226fa..00000000
--- a/src/Attributes/WpCli/AfterInvoke.php
+++ /dev/null
@@ -1,24 +0,0 @@
-commandName ?? Slug::fromMethodName($methodName);
- }
-}
diff --git a/src/Attributes/WpCli/IsDeferred.php b/src/Attributes/WpCli/IsDeferred.php
deleted file mode 100644
index bcaeaea6..00000000
--- a/src/Attributes/WpCli/IsDeferred.php
+++ /dev/null
@@ -1,25 +0,0 @@
-|Permission|null $permissionCallback The permission for the route: a Permission class, or an instance such as `new Can('edit_posts')`.
- */
- public function __construct(
- public readonly string $namespace,
- public readonly string $route,
- public readonly string|Permission|null $permissionCallback = null
- ) {}
-
- /**
- * Handle the attribute processing.
- *
- * @param object $serviceLocator Service locator used to resolve dependencies
- * @param object $instance The instance to which the attribute applies
- * @param ReflectionClass|ReflectionMethod $context The reflection context.
- * @param object $attribute The attribute instance containing provided arguments.
- */
- public function handle($serviceLocator, object $instance, ReflectionClass|ReflectionMethod $context, object $attribute): void
- {
- $instance->namespace = $attribute->namespace;
- $instance->route = $attribute->route;
- $instance->classPermission = $attribute->permissionCallback;
- }
-}
diff --git a/src/Attributes/WpRestRoute/Method.php b/src/Attributes/WpRestRoute/Method.php
deleted file mode 100755
index a43417bb..00000000
--- a/src/Attributes/WpRestRoute/Method.php
+++ /dev/null
@@ -1,183 +0,0 @@
-|Permission|null $permissionCallback The permission for this method, replacing the route's: a Permission class, or an instance such as `new Can('edit_posts')`.
- *
- * @throws InvalidArgumentException If an invalid HTTP method is provided.
- */
- public function __construct(
- public array|string $methods,
- public string|Permission|null $permissionCallback = null
- ) {
- $this->methods = is_array($methods) ? $methods : [$methods];
- $this->validateMethods();
- }
-
- /**
- * Validates the provided HTTP methods.
- *
- * @throws InvalidArgumentException If an invalid HTTP method is found.
- */
- private function validateMethods(): void
- {
- foreach ($this->methods as $method) {
- if (! in_array(strtoupper((string) $method), self::ALLOWED_METHODS, true)) {
- throw new InvalidArgumentException(
- sprintf(
- 'Invalid HTTP method "%s". Allowed methods are: %s',
- $method,
- implode(', ', self::ALLOWED_METHODS)
- )
- );
- }
- }
- }
-
- /**
- * Retrieves the list of HTTP methods.
- *
- * @return array The list of allowed HTTP methods.
- */
- public function getMethods(): array
- {
- return $this->methods;
- }
-
- /**
- * Handles the registration of the REST route for the method.
- *
- * @param object $serviceLocator Service locator used to resolve dependencies
- * @param Attributable $instance The class instance
- * @param ReflectionMethod|ReflectionClass $context The reflection context
- * @param object $attribute The attribute instance
- */
- public function handle($serviceLocator, Attributable $instance, ReflectionMethod|ReflectionClass $context, object $attribute): void
- {
- $methodPermission = $attribute->permissionCallback;
- $permissionCallback = $methodPermission ?? $instance->classPermission;
-
- register_rest_route(
- $instance->namespace,
- $instance->route,
- [
- 'methods' => $attribute->getMethods(),
- 'callback' => WpGlobals::wrap(
- fn (WP_REST_Request $request): mixed => $this->handleRequest($instance, $context, $request)
- ),
- 'args' => $this->extractArgsFromRoute($instance->route),
- 'permission_callback' => $this->resolvePermissionCallback($permissionCallback),
- ]
- );
- }
-
- /**
- * Executes the class method associated with the REST request.
- *
- * @param Attributable $instance The class instance
- * @param ReflectionMethod $method The method to invoke
- * @param WP_REST_Request $request The REST request instance
- * @return mixed The result of the method invocation
- */
- private function handleRequest(Attributable $instance, ReflectionMethod $method, WP_REST_Request $request): mixed
- {
- $args = [];
-
- foreach ($method->getParameters() as $param) {
- // A WP_REST_Request type hint gets the request itself. Looking its
- // name up among the route parameters found nothing — no route
- // declares one called "request" — so the handler was invoked with
- // null and a controller declaring the type fataled before running.
- if ($this->expectsTheRequest($param)) {
- $args[] = $request;
-
- continue;
- }
-
- $args[] = $request->get_param($param->getName());
- }
-
- // Get the real instance if available, otherwise use the provided instance
- $realInstance = method_exists($instance, 'getRealInstance') ? $instance->getRealInstance() : $instance;
-
- return $method->invoke($realInstance, ...$args);
- }
-
- /**
- * Whether a parameter asks for the request object rather than a route value.
- */
- private function expectsTheRequest(\ReflectionParameter $param): bool
- {
- $type = $param->getType();
-
- if (! $type instanceof \ReflectionNamedType || $type->isBuiltin()) {
- return false;
- }
-
- $name = $type->getName();
-
- // The name comparison stands on its own: ::class resolves whether or
- // not WordPress is loaded, so this holds outside a WordPress runtime.
- return $name === WP_REST_Request::class
- || is_a($name, WP_REST_Request::class, true);
- }
-
- /**
- * Extracts dynamic arguments from a WordPress route.
- *
- * @param string $route The route pattern
- * @return array The extracted arguments
- */
- private function extractArgsFromRoute(string $route): array
- {
- preg_match_all('/\(\?P<(\w+)>/', $route, $matches);
-
- return array_fill_keys($matches[1], [
- 'validate_callback' => fn ($param): bool => is_string($param) || is_numeric($param),
- ]);
- }
-
- /**
- * Resolves and executes the permission callback.
- *
- * @param class-string|Permission|null $permissionCallback The permission class or instance to use
- * @return callable The permission function
- */
- private function resolvePermissionCallback(string|Permission|null $permissionCallback): callable
- {
- if ($permissionCallback === null) {
- return '__return_true';
- }
-
- if (is_string($permissionCallback) && (! class_exists($permissionCallback) || ! is_subclass_of($permissionCallback, Permission::class))) {
- return fn (): WP_Error => new WP_Error('rest_forbidden', __('Invalid permission handler.'), ['status' => 403]);
- }
-
- return WpGlobals::wrap(function (WP_REST_Request $request) use ($permissionCallback): bool|WP_Error {
- $permissionInstance = is_string($permissionCallback) ? new $permissionCallback : $permissionCallback;
-
- return $permissionInstance->allow($request);
- });
- }
-}
diff --git a/src/Attributes/WpRestRoute/Permission.php b/src/Attributes/WpRestRoute/Permission.php
deleted file mode 100755
index f535a20a..00000000
--- a/src/Attributes/WpRestRoute/Permission.php
+++ /dev/null
@@ -1,20 +0,0 @@
-engine->addLocation($location);
-
- return $this;
- }
-
- /**
- * Add multiple discovery locations
- *
- * @param array $paths Array of filesystem paths
- * @return static Returns self for method chaining
- */
- public function addLocations(array $paths): static
- {
- foreach ($paths as $path) {
- $this->addLocation($path);
- }
-
- return $this;
- }
-
- /**
- * Register a discovery class
- *
- * @param string $identifier Unique identifier for the discovery
- * @param string|DiscoveryInterface $discovery Discovery class name or instance
- * @return static Returns self for method chaining
- */
- public function addDiscovery(string $identifier, string|DiscoveryInterface $discovery): static
- {
- $this->engine->addDiscovery($identifier, $discovery);
-
- return $this;
- }
-
- /**
- * Register multiple discovery classes
- *
- * @param array $discoveries Map of identifier to discovery
- * @return static Returns self for method chaining
- */
- public function addDiscoveries(array $discoveries): static
- {
- $this->engine->addDiscoveries($discoveries);
-
- return $this;
- }
-
- /**
- * Execute discovery and apply all discoveries
- *
- * This is the main method that runs the entire discovery process.
- *
- * @return static Returns self for method chaining
- */
- public function run(): static
- {
- $this->engine->run();
-
- return $this;
- }
-
- /**
- * Execute only the discovery phase
- *
- * Useful for testing or when you want to inspect discovered items
- * before applying them.
- *
- * @return static Returns self for method chaining
- */
- public function discover(): static
- {
- $this->engine->discover();
-
- return $this;
- }
-
- /**
- * Execute only the application phase
- *
- * Applies previously discovered items. Should be called after discover().
- *
- * @return static Returns self for method chaining
- */
- public function apply(): static
- {
- $this->engine->apply();
-
- return $this;
- }
-
- /**
- * Get a specific discovery by identifier
- *
- * @param string $identifier The discovery identifier
- * @return DiscoveryInterface The discovery instance
- */
- public function getDiscovery(string $identifier): DiscoveryInterface
- {
- return $this->engine->getDiscovery($identifier);
- }
-
- /**
- * Get all registered discoveries
- *
- * @return Collection Collection of discoveries keyed by identifier
- */
- public function getDiscoveries(): Collection
- {
- return $this->engine->getDiscoveries();
- }
-
- /**
- * Get all discovery locations
- *
- * @return Collection Collection of discovery locations
- */
- public function getLocations(): Collection
- {
- return $this->engine->getLocations();
- }
-
- /**
- * Clear persistent discovery cache
- *
- * Only clears persistent cache (Spatie structure discoverer cache).
- * In-memory caches are automatically cleared at process end.
- *
- * @return static Returns self for method chaining
- */
- public function clearCache(): static
- {
- $this->engine->clearCache();
-
- return $this;
- }
-
- /**
- * Check if a discovery is registered
- *
- * @param string $identifier The discovery identifier to check
- * @return bool True if the discovery is registered, false otherwise
- */
- public function hasDiscovery(string $identifier): bool
- {
- try {
- $this->getDiscovery($identifier);
-
- return true;
- } catch (\Throwable) {
- return false;
- }
- }
-
- /**
- * Get discovered items for a specific discovery
- *
- * @param string $identifier The discovery identifier
- * @return array The discovered items
- */
- public function getDiscoveredItems(string $identifier): array
- {
- $discovery = $this->getDiscovery($identifier);
-
- return $discovery->getItems()->all();
- }
-
- /**
- * Run a specific discovery type on a specific location
- *
- * @param string $identifier The discovery identifier
- * @param DiscoveryLocationInterface $location The location to discover in
- * @return static Returns self for method chaining
- */
- public function runSpecificDiscovery(string $identifier, DiscoveryLocationInterface $location): static
- {
- if (! $this->hasDiscovery($identifier)) {
- return $this;
- }
-
- // Create a temporary engine with just this discovery and location
- $tempEngine = clone $this->engine;
- $tempEngine->clearLocations();
- $tempEngine->addLocation($location);
-
- // Run only this specific discovery
- $discovery = $this->getDiscovery($identifier);
- $tempEngine->runDiscovery($identifier, $discovery);
-
- return $this;
- }
-
- /**
- * Discover all structure types in a location
- *
- * @param DiscoveryLocationInterface $location The location to discover in
- * @return array Results grouped by discovery type
- */
- public function discoverAllInLocation(DiscoveryLocationInterface $location): array
- {
- $results = [];
-
- // Create a temporary engine for this location
- $tempEngine = clone $this->engine;
- $tempEngine->clearLocations();
- $tempEngine->addLocation($location);
-
- // Run discovery and collect results by type
- $tempEngine->discover();
-
- foreach ($this->getDiscoveries() as $identifier => $discovery) {
- $items = $discovery->getItems()->all();
- if (! empty($items)) {
- $results[$identifier] = $items;
- }
- }
-
- return $results;
- }
-
- /**
- * Get the underlying discovery engine
- *
- * @return DiscoveryEngineInterface The discovery engine instance
- */
- public function getEngine(): DiscoveryEngineInterface
- {
- return $this->engine;
- }
-
- /**
- * Get performance statistics from the optimized engine
- *
- * @return array Performance statistics or empty array if not available
- */
- public function getPerformanceStats(): array
- {
- if (method_exists($this->engine, 'getPerformanceStats')) {
- return $this->engine->getPerformanceStats();
- }
-
- return [];
- }
-
- /**
- * Get debugging information about the discovery state
- *
- * @return array Debug information
- */
- public function getDebugInfo(): array
- {
- $debugInfo = [
- 'discoveries_count' => $this->getDiscoveries()->count(),
- 'locations_count' => $this->getLocations()->count(),
- 'discoveries' => [],
- 'locations' => [],
- ];
-
- // Add discovery details
- foreach ($this->getDiscoveries() as $identifier => $discovery) {
- $items = $this->getDiscoveredItems($identifier);
- $debugInfo['discoveries'][$identifier] = [
- 'class' => $discovery::class,
- 'items_count' => count($items),
- 'items' => array_slice($items, 0, 3), // Show first 3 items for debugging
- ];
- }
-
- // Add location details
- foreach ($this->getLocations() as $location) {
- $debugInfo['locations'][] = [
- 'path' => $location->getPath(),
- 'exists' => is_dir($location->getPath()),
- ];
- }
-
- return $debugInfo;
- }
-}
diff --git a/src/Discovery/Domain/Contracts/DiscoversPathInterface.php b/src/Discovery/Domain/Contracts/DiscoversPathInterface.php
deleted file mode 100644
index b1bccd32..00000000
--- a/src/Discovery/Domain/Contracts/DiscoversPathInterface.php
+++ /dev/null
@@ -1,30 +0,0 @@
- $locations The discovery locations
- * @return string The generated cache key
- */
- public function generateKey(string $discoveryIdentifier, array $locations): string;
-}
diff --git a/src/Discovery/Domain/Contracts/DiscoveryEngineInterface.php b/src/Discovery/Domain/Contracts/DiscoveryEngineInterface.php
deleted file mode 100644
index 4b875649..00000000
--- a/src/Discovery/Domain/Contracts/DiscoveryEngineInterface.php
+++ /dev/null
@@ -1,139 +0,0 @@
- $locations The locations to add
- * @return static Returns self for method chaining
- */
- public function addLocations(array $locations): static;
-
- /**
- * Register a discovery class
- *
- * @param string $identifier Unique identifier for the discovery
- * @param string|DiscoveryInterface $discovery Discovery class name or instance
- * @return static Returns self for method chaining
- */
- public function addDiscovery(string $identifier, string|DiscoveryInterface $discovery): static;
-
- /**
- * Register multiple discovery classes
- *
- * @param array $discoveries Map of identifier to discovery
- * @return static Returns self for method chaining
- */
- public function addDiscoveries(array $discoveries): static;
-
- /**
- * Execute discovery process for all registered discoveries
- *
- * Runs the discovery phase for all registered discovery classes
- * across all configured locations.
- *
- * @return static Returns self for method chaining
- *
- * @throws DiscoveryException When discovery fails
- */
- public function discover(): static;
-
- /**
- * Apply all discoveries
- *
- * Applies all discovered items by calling the apply() method
- * on all registered discovery classes.
- *
- * @return static Returns self for method chaining
- *
- * @throws DiscoveryException When application fails
- */
- public function apply(): static;
-
- /**
- * Execute discovery and apply in one operation
- *
- * Convenience method that runs both discover() and apply().
- *
- * @return static Returns self for method chaining
- *
- * @throws DiscoveryException When discovery or application fails
- */
- public function run(): static;
-
- /**
- * Get a specific discovery by identifier
- *
- * @param string $identifier The discovery identifier
- * @return DiscoveryInterface The discovery instance
- *
- * @throws DiscoveryNotFoundException When discovery not found
- */
- public function getDiscovery(string $identifier): DiscoveryInterface;
-
- /**
- * Get all registered discoveries
- *
- * @return Collection Collection of discoveries keyed by identifier
- */
- public function getDiscoveries(): Collection;
-
- /**
- * Get all discovery locations
- *
- * @return Collection Collection of discovery locations
- */
- public function getLocations(): Collection;
-
- /**
- * Clear persistent discovery cache.
- *
- * @return static Returns self for method chaining
- */
- public function clearCache(): static;
-
- /**
- * Clear all registered discovery locations.
- *
- * @return static Returns self for method chaining
- */
- public function clearLocations(): static;
-
- /**
- * Run a specific discovery by identifier.
- *
- * @param string $identifier The discovery identifier
- * @param DiscoveryInterface $discovery The discovery instance to run
- * @return static Returns self for method chaining
- */
- public function runDiscovery(string $identifier, DiscoveryInterface $discovery): static;
-}
diff --git a/src/Discovery/Domain/Contracts/DiscoveryInterface.php b/src/Discovery/Domain/Contracts/DiscoveryInterface.php
deleted file mode 100644
index 041d3b18..00000000
--- a/src/Discovery/Domain/Contracts/DiscoveryInterface.php
+++ /dev/null
@@ -1,73 +0,0 @@
-
- */
-interface DiscoveryItemsInterface extends Countable, IteratorAggregate
-{
- /**
- * Add multiple values for a specific location
- *
- * @param DiscoveryLocationInterface $location The discovery location
- * @param array $values The values to add
- * @return static Returns self for method chaining
- */
- public function addForLocation(DiscoveryLocationInterface $location, array $values): static;
-
- /**
- * Get all values for a specific location
- *
- * @param DiscoveryLocationInterface $location The discovery location
- * @return array The values for the location
- */
- public function getForLocation(DiscoveryLocationInterface $location): array;
-
- /**
- * Add a single value for a specific location
- *
- * @param DiscoveryLocationInterface $location The discovery location
- * @param mixed $value The value to add
- * @return static Returns self for method chaining
- */
- public function add(DiscoveryLocationInterface $location, mixed $value): static;
-
- /**
- * Check if a location has any discovered items
- *
- * @param DiscoveryLocationInterface $location The discovery location
- * @return bool True if the location has items, false otherwise
- */
- public function hasLocation(DiscoveryLocationInterface $location): bool;
-
- /**
- * Check if any items have been loaded
- *
- * @return bool True if items are loaded, false if empty
- */
- public function isLoaded(): bool;
-
- /**
- * Filter to only include vendor locations
- *
- * Creates a new instance containing only items from vendor directories.
- *
- * @return static A new instance with only vendor items
- */
- public function onlyVendor(): static;
-
- /**
- * Get all items flattened into a single array
- *
- * @return array All discovered items
- */
- public function all(): array;
-
- /**
- * Get the items not applied yet
- *
- * Creates a new instance holding, for each location, the items added
- * since the last call to markApplied().
- *
- * @return static A new instance with only the pending items
- */
- public function pending(): static;
-
- /**
- * Mark every current item as applied
- *
- * Items added afterwards are the next pending() ones.
- */
- public function markApplied(): void;
-
- /**
- * Serialize the discovery items for caching.
- *
- * @return array
- */
- public function __serialize(): array;
-}
diff --git a/src/Discovery/Domain/Contracts/DiscoveryLocationInterface.php b/src/Discovery/Domain/Contracts/DiscoveryLocationInterface.php
deleted file mode 100644
index 7fcb8745..00000000
--- a/src/Discovery/Domain/Contracts/DiscoveryLocationInterface.php
+++ /dev/null
@@ -1,36 +0,0 @@
- Array of methods with the specified attributes
- */
- public function getMethodsWithAttributes(string $className, ?string $attributeClass = null): array;
-
- /**
- * Get cached class-level attributes for a class.
- *
- * @param string $className The fully qualified class name
- * @param string|null $attributeClass Optional filter by attribute class
- * @return array<\ReflectionAttribute> Array of class attributes
- */
- public function getClassAttributes(string $className, ?string $attributeClass = null): array;
-
- /**
- * Get cached public methods for a class.
- *
- * Eliminates redundant getMethods(IS_PUBLIC) calls when multiple
- * discoveries inspect the same class.
- *
- * @param string $className The fully qualified class name
- * @return array Array of public methods
- */
- public function getPublicMethods(string $className): array;
-
- /**
- * Get cached class instance from the DI container.
- *
- * @param string $className The fully qualified class name
- * @return object The cached class instance
- *
- * @throws \Throwable If the class cannot be instantiated
- */
- public function getClassInstance(string $className): object;
-
- /**
- * Check if a class has been processed by reflection cache.
- *
- * @param string $className The fully qualified class name
- * @return bool True if the class is cached
- */
- public function hasClass(string $className): bool;
-
- /**
- * Preload multiple classes into the cache.
- *
- * @param array $classNames Array of class names to preload
- */
- public function preloadClasses(array $classNames): void;
-}
diff --git a/src/Discovery/Domain/Exceptions/DiscoveryException.php b/src/Discovery/Domain/Exceptions/DiscoveryException.php
deleted file mode 100644
index 55a1f519..00000000
--- a/src/Discovery/Domain/Exceptions/DiscoveryException.php
+++ /dev/null
@@ -1,44 +0,0 @@
-path;
- }
-
- /**
- * Check if the directory exists
- *
- * @return bool True if the directory exists and is readable
- */
- public function exists(): bool
- {
- return is_dir($this->path) && is_readable($this->path);
- }
-
- /**
- * {@inheritDoc}
- */
- public function getKey(): string
- {
- return md5($this->path);
- }
-
- /**
- * {@inheritDoc}
- */
- public function isVendor(): bool
- {
- return str_contains($this->path, '/vendor/') || str_contains($this->path, '\\vendor\\');
- }
-
- /**
- * Get a string representation of the location
- *
- * @return string The string representation of the location
- */
- public function __toString(): string
- {
- return $this->path;
- }
-}
diff --git a/src/Discovery/Domain/Models/DiscoveryContext.php b/src/Discovery/Domain/Models/DiscoveryContext.php
deleted file mode 100644
index 4bab9a89..00000000
--- a/src/Discovery/Domain/Models/DiscoveryContext.php
+++ /dev/null
@@ -1,252 +0,0 @@
->
- */
- private array $processedClasses = [];
-
- /**
- * Shared metadata between discoveries
- *
- * @var array>
- */
- private array $sharedData = [];
-
- /**
- * Current processing stats
- *
- * @var array
- */
- private array $processingStats = [
- 'classes_processed' => 0,
- 'discoveries_executed' => 0,
- 'cache_hits' => 0,
- 'cache_misses' => 0,
- 'errors' => 0,
- ];
-
- /**
- * Create a new discovery context
- *
- * @param ReflectionCacheInterface $reflectionCache The reflection cache service
- */
- public function __construct(
- private readonly ReflectionCacheInterface $reflectionCache
- ) {}
-
- /**
- * Mark a class as processed by a specific discovery type.
- *
- * @param string $className The fully qualified class name
- * @param string $discoveryType The discovery type identifier
- */
- public function markProcessed(string $className, string $discoveryType): void
- {
- $this->processedClasses[$className][$discoveryType] = true;
- }
-
- /**
- * Check if a class has been processed by a specific discovery type.
- *
- * @param string $className The fully qualified class name
- * @param string $discoveryType The discovery type identifier
- * @return bool True if the class has been processed
- */
- public function isProcessed(string $className, string $discoveryType): bool
- {
- return $this->processedClasses[$className][$discoveryType] ?? false;
- }
-
- /**
- * Check if a class has been processed by any discovery type.
- *
- * @param string $className The fully qualified class name
- * @return bool True if the class has been processed by any discovery
- */
- public function isClassProcessed(string $className): bool
- {
- return isset($this->processedClasses[$className]) &&
- (isset($this->processedClasses[$className]) && $this->processedClasses[$className] !== []);
- }
-
- /**
- * Get the list of discovery types that have processed a class.
- *
- * @param string $className The fully qualified class name
- * @return array Array of discovery type identifiers
- */
- public function getProcessedDiscoveries(string $className): array
- {
- return array_keys($this->processedClasses[$className] ?? []);
- }
-
- /**
- * Store shared data for a class that can be accessed by other discoveries.
- *
- * @param string $className The fully qualified class name
- * @param string $key The data key
- * @param mixed $value The data value
- */
- public function setSharedData(string $className, string $key, mixed $value): void
- {
- $this->sharedData[$className][$key] = $value;
- }
-
- /**
- * Get shared data for a class.
- *
- * @param string $className The fully qualified class name
- * @param string|null $key Optional specific key to retrieve
- * @return mixed The shared data value or array of all data
- */
- public function getSharedData(string $className, ?string $key = null): mixed
- {
- if ($key === null) {
- return $this->sharedData[$className] ?? [];
- }
-
- return $this->sharedData[$className][$key] ?? null;
- }
-
- /**
- * Check if shared data exists for a class and key.
- *
- * @param string $className The fully qualified class name
- * @param string $key The data key
- * @return bool True if the data exists
- */
- public function hasSharedData(string $className, string $key): bool
- {
- return isset($this->sharedData[$className][$key]);
- }
-
- /**
- * Get the reflection cache instance.
- */
- public function getReflectionCache(): ReflectionCacheInterface
- {
- return $this->reflectionCache;
- }
-
- /**
- * Preload a batch of classes into the reflection cache.
- *
- * @param array $structures Array of discovered structures
- */
- public function preloadStructures(array $structures): void
- {
- $classNames = [];
-
- foreach ($structures as $structure) {
- if ($structure instanceof DiscoveredClass &&
- ! $structure->isAbstract) {
- $classNames[] = $structure->namespace.'\\'.$structure->name;
- }
- }
-
- $this->reflectionCache->preloadClasses($classNames);
- }
-
- /**
- * Increment a processing statistic.
- *
- * @param string $stat The statistic name
- * @param int $increment The increment value
- */
- public function incrementStat(string $stat, int $increment = 1): void
- {
- $this->processingStats[$stat] = ($this->processingStats[$stat] ?? 0) + $increment;
- }
-
- /**
- * Get processing statistics.
- *
- * @return array Array of processing statistics
- */
- public function getStats(): array
- {
- return $this->processingStats;
- }
-
- /**
- * Record a cache hit.
- */
- public function recordCacheHit(): void
- {
- $this->incrementStat('cache_hits');
- }
-
- /**
- * Record a cache miss.
- */
- public function recordCacheMiss(): void
- {
- $this->incrementStat('cache_misses');
- }
-
- /**
- * Record a processing error.
- */
- public function recordError(): void
- {
- $this->incrementStat('errors');
- }
-
- /**
- * Get cache efficiency as a percentage.
- *
- * @return float Cache hit ratio as percentage
- */
- public function getCacheEfficiency(): float
- {
- $hits = $this->processingStats['cache_hits'];
- $misses = $this->processingStats['cache_misses'];
- $total = $hits + $misses;
-
- return $total > 0 ? round(($hits / $total) * 100, 2) : 0.0;
- }
-
- /**
- * Get a summary of the discovery context state.
- *
- * @return array Summary information
- */
- public function getSummary(): array
- {
- $totalClasses = count($this->processedClasses);
- $totalDiscoveries = array_sum(array_map(count(...), $this->processedClasses));
-
- return [
- 'total_classes' => $totalClasses,
- 'total_discovery_executions' => $totalDiscoveries,
- 'cache_efficiency' => $this->getCacheEfficiency(),
- 'shared_data_entries' => array_sum(array_map(count(...), $this->sharedData)),
- 'stats' => $this->processingStats,
- ];
- }
-}
diff --git a/src/Discovery/Domain/Models/DiscoveryItems.php b/src/Discovery/Domain/Models/DiscoveryItems.php
deleted file mode 100644
index de3a2ff7..00000000
--- a/src/Discovery/Domain/Models/DiscoveryItems.php
+++ /dev/null
@@ -1,192 +0,0 @@
-> $items Initial items organized by location key
- */
- public function __construct(
- /**
- * The discovered items organized by location key
- */
- private array $items = []
- ) {}
-
- /**
- * How many items of each location have been applied, by location key
- *
- * @var array
- */
- private array $applied = [];
-
- /**
- * {@inheritDoc}
- */
- public function addForLocation(DiscoveryLocationInterface $location, array $values): static
- {
- $locationKey = $location->getKey();
- $existingValues = $this->items[$locationKey] ?? [];
-
- $this->items[$locationKey] = [...$existingValues, ...$values];
-
- return $this;
- }
-
- /**
- * {@inheritDoc}
- */
- public function getForLocation(DiscoveryLocationInterface $location): array
- {
- return $this->items[$location->getKey()] ?? [];
- }
-
- /**
- * {@inheritDoc}
- */
- public function add(DiscoveryLocationInterface $location, mixed $value): static
- {
- $locationKey = $location->getKey();
- $this->items[$locationKey] ??= [];
- $this->items[$locationKey][] = $value;
-
- return $this;
- }
-
- /**
- * {@inheritDoc}
- */
- public function hasLocation(DiscoveryLocationInterface $location): bool
- {
- return array_key_exists($location->getKey(), $this->items);
- }
-
- /**
- * {@inheritDoc}
- */
- public function isLoaded(): bool
- {
- return $this->items !== [];
- }
-
- /**
- * {@inheritDoc}
- */
- public function onlyVendor(): static
- {
- $vendorItems = [];
-
- foreach ($this->items as $locationKey => $items) {
- // We need to check if the location is vendor, but we only have the key
- // This is a limitation - we'd need to store location objects or add vendor flag
- // For now, we'll use a simple heuristic based on common vendor paths
- if ($this->isVendorLocation()) {
- $vendorItems[$locationKey] = $items;
- }
- }
-
- return new self($vendorItems);
- }
-
- /**
- * {@inheritDoc}
- */
- public function all(): array
- {
- return $this->items === [] ? [] : array_merge(...array_values($this->items));
- }
-
- /**
- * {@inheritDoc}
- */
- public function pending(): static
- {
- $pending = [];
-
- foreach ($this->items as $locationKey => $items) {
- $remaining = array_slice($items, $this->applied[$locationKey] ?? 0);
-
- if ($remaining !== []) {
- $pending[$locationKey] = $remaining;
- }
- }
-
- return new self($pending);
- }
-
- /**
- * {@inheritDoc}
- */
- public function markApplied(): void
- {
- $this->applied = array_map(count(...), $this->items);
- }
-
- /**
- * {@inheritDoc}
- */
- public function getIterator(): Traversable
- {
- return new ArrayIterator($this->all());
- }
-
- /**
- * {@inheritDoc}
- */
- public function count(): int
- {
- return count($this->all());
- }
-
- /**
- * Check if a location key represents a vendor location
- *
- * This is a heuristic approach since we only have the CRC32 key.
- * In a real implementation, we might need to store more location metadata.
- *
- * @return bool True if likely a vendor location
- */
- private function isVendorLocation(): bool
- {
- // This is a simplified heuristic - in practice, you might want to
- // store additional metadata about locations or use a different approach
- return false;
- }
-
- /**
- * Serialize the items for caching
- *
- * @return array>
- */
- public function __serialize(): array
- {
- return $this->items;
- }
-
- /**
- * Unserialize the items from cache
- *
- * @param array> $data The serialized data
- */
- public function __unserialize(array $data): void
- {
- $this->items = $data;
- }
-}
diff --git a/src/Discovery/Domain/Models/DiscoveryLocation.php b/src/Discovery/Domain/Models/DiscoveryLocation.php
deleted file mode 100644
index 4abb7107..00000000
--- a/src/Discovery/Domain/Models/DiscoveryLocation.php
+++ /dev/null
@@ -1,104 +0,0 @@
-path = $this->resolvePath($path);
- }
-
- /**
- * {@inheritDoc}
- */
- public function getNamespace(): string
- {
- return $this->namespace;
- }
-
- /**
- * {@inheritDoc}
- */
- public function getPath(): string
- {
- return $this->path;
- }
-
- /**
- * {@inheritDoc}
- */
- public function getKey(): string
- {
- return (string) crc32($this->path);
- }
-
- /**
- * {@inheritDoc}
- */
- public function isVendor(): bool
- {
- return str_contains($this->path, '/vendor/') || str_contains($this->path, '\\vendor\\');
- }
-
- /**
- * {@inheritDoc}
- */
- public function toClassName(string $filePath): string
- {
- // Ensure the file path is within this location
- if (! str_starts_with($filePath, $this->path)) {
- return '';
- }
-
- // Convert file path to class name
- $relativePath = substr($filePath, strlen($this->path));
- $relativePath = ltrim($relativePath, '/\\');
-
- // Remove .php extension
- $relativePath = preg_replace('/\.php$/', '', $relativePath);
-
- // Convert path separators to namespace separators
- $className = str_replace(['/', '\\'], '\\', $relativePath);
-
- // Combine with base namespace
- return rtrim($this->namespace, '\\').'\\'.$className;
- }
-
- /**
- * Resolve the absolute path
- *
- * @param string $path The path to resolve
- * @return string The resolved absolute path
- */
- private function resolvePath(string $path): string
- {
- $resolved = realpath(rtrim($path, '\\/'));
-
- return $resolved !== false ? $resolved : rtrim($path, '\\/');
- }
-}
diff --git a/src/Discovery/Domain/Services/HasInstancePool.php b/src/Discovery/Domain/Services/HasInstancePool.php
deleted file mode 100644
index 5c5e9b8d..00000000
--- a/src/Discovery/Domain/Services/HasInstancePool.php
+++ /dev/null
@@ -1,58 +0,0 @@
-instancePool = $instancePool;
- }
-
- /**
- * Get an instance from the pool if available, otherwise use fallback.
- *
- * @param string $className The class name to instantiate
- * @param callable|null $fallback Optional fallback creation function
- * @return object The class instance
- */
- protected function getInstanceFromPool(string $className, ?callable $fallback = null): object
- {
- if ($this->instancePool !== null) {
- return $this->instancePool->getInstance($className);
- }
-
- if ($fallback !== null) {
- return $fallback();
- }
-
- return resolve($className);
- }
-
- /**
- * Check if instance pool is available.
- */
- protected function hasInstancePool(): bool
- {
- return $this->instancePool !== null;
- }
-}
diff --git a/src/Discovery/Domain/Services/IsDiscovery.php b/src/Discovery/Domain/Services/IsDiscovery.php
deleted file mode 100644
index 81c8359d..00000000
--- a/src/Discovery/Domain/Services/IsDiscovery.php
+++ /dev/null
@@ -1,54 +0,0 @@
-discoveryItems ??= new DiscoveryItems;
-
- return $this->discoveryItems;
- }
-
- /**
- * {@inheritDoc}
- */
- public function setItems(DiscoveryItemsInterface $items): void
- {
- $this->discoveryItems = $items;
- }
-
- /**
- * {@inheritDoc}
- */
- public function getIdentifier(): string
- {
- return static::class;
- }
-}
diff --git a/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCache.php b/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCache.php
index 4d12e9a7..a3767ced 100644
--- a/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCache.php
+++ b/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCache.php
@@ -15,6 +15,8 @@
*
* Implementation of discovery cache using Laravel's cache system.
* Provides persistent caching of discovery results to improve performance.
+ *
+ * @deprecated Removed in 15.0, with DiscoveryCacheInterface.
*/
final readonly class LaravelDiscoveryCache implements DiscoveryCacheInterface
{
diff --git a/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCacheManagerFactory.php b/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCacheManagerFactory.php
new file mode 100644
index 00000000..3ce56e9a
--- /dev/null
+++ b/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCacheManagerFactory.php
@@ -0,0 +1,73 @@
+debugDetector->isDebugMode();
+
+ return new DiscoveryCacheManager(
+ cacheDriver: $isDebugMode ? new NullDiscoverCacheDriver : $this->resolveCacheDriver(),
+ logger: $this->resolveLogger(),
+ warnOnSlowScans: $isDebugMode,
+ );
+ }
+
+ private function resolveCacheDriver(): DiscoverCacheDriver
+ {
+ /** @var array{driver?: class-string, store?: string|null} $cacheConfig */
+ $cacheConfig = $this->container->bound('config')
+ ? (array) $this->container->make('config')->get('structure-discoverer.cache', [])
+ : [];
+
+ $driverClass = $cacheConfig['driver'] ?? LaravelDiscoverCacheDriver::class;
+ $store = $cacheConfig['store'] ?? null;
+
+ if ($driverClass === LaravelDiscoverCacheDriver::class) {
+ return new LaravelDiscoverCacheDriver(prefix: 'pollora', store: $store);
+ }
+
+ try {
+ return $this->container->make($driverClass);
+ } catch (\Throwable) {
+ return new LaravelDiscoverCacheDriver(prefix: 'pollora', store: $store);
+ }
+ }
+
+ private function resolveLogger(): ?LoggerInterface
+ {
+ try {
+ return $this->container->bound(LoggerInterface::class)
+ ? $this->container->make(LoggerInterface::class)
+ : null;
+ } catch (\Throwable) {
+ // A missing logger must not keep discovery from starting.
+ return null;
+ }
+ }
+}
diff --git a/src/Discovery/Infrastructure/Adapters/LaravelInstanceResolver.php b/src/Discovery/Infrastructure/Adapters/LaravelInstanceResolver.php
new file mode 100644
index 00000000..bc5e00f6
--- /dev/null
+++ b/src/Discovery/Infrastructure/Adapters/LaravelInstanceResolver.php
@@ -0,0 +1,27 @@
+container->make($className);
+ }
+}
diff --git a/src/Discovery/Infrastructure/Providers/DiscoveryServiceProvider.php b/src/Discovery/Infrastructure/Providers/DiscoveryServiceProvider.php
index c509d705..f11ab8f5 100644
--- a/src/Discovery/Infrastructure/Providers/DiscoveryServiceProvider.php
+++ b/src/Discovery/Infrastructure/Providers/DiscoveryServiceProvider.php
@@ -4,13 +4,19 @@
namespace Pollora\Discovery\Infrastructure\Providers;
+use Illuminate\Container\Container;
use Illuminate\Support\ServiceProvider;
use Pollora\Application\Domain\Contracts\DebugDetectorInterface;
use Pollora\Discovery\Application\Services\DiscoveryManager;
use Pollora\Discovery\Domain\Contracts\DiscoveryEngineInterface;
+use Pollora\Discovery\Domain\Contracts\InstanceResolverInterface;
use Pollora\Discovery\Domain\Contracts\ReflectionCacheInterface;
use Pollora\Discovery\Domain\Models\DirectoryLocation;
+use Pollora\Discovery\Infrastructure\Adapters\LaravelDiscoveryCacheManagerFactory;
+use Pollora\Discovery\Infrastructure\Adapters\LaravelInstanceResolver;
+use Pollora\Discovery\Infrastructure\Services\DiscoveryCacheManager;
use Pollora\Discovery\Infrastructure\Services\DiscoveryEngine;
+use Pollora\Discovery\Infrastructure\Services\DiscoveryRegistrar;
use Pollora\Discovery\Infrastructure\Services\InstancePool;
use Pollora\Discovery\Infrastructure\Services\ReflectionCache;
use Pollora\Discovery\Infrastructure\Services\ServiceProviderDiscovery;
@@ -64,7 +70,9 @@ public function provides(): array
DiscoveryEngineInterface::class,
DiscoveryManager::class,
ReflectionCacheInterface::class,
+ InstanceResolverInterface::class,
InstancePool::class,
+ DiscoveryCacheManager::class,
DiscoveryCommand::class,
DiscoveryClearCommand::class,
];
@@ -75,11 +83,24 @@ public function provides(): array
*/
private function registerOptimizedServices(): void
{
+ // Discovery builds classes through Laravel's container
+ $this->app->singleton(InstanceResolverInterface::class, fn (Container $app): LaravelInstanceResolver => new LaravelInstanceResolver($app));
+
// Register reflection cache as singleton for maximum efficiency
- $this->app->singleton(ReflectionCacheInterface::class, ReflectionCache::class);
+ $this->app->singleton(ReflectionCacheInterface::class, fn (Container $app): ReflectionCache => new ReflectionCache(
+ $app->make(InstanceResolverInterface::class)
+ ));
// Register instance pool as singleton to maintain state across discoveries
- $this->app->singleton(InstancePool::class);
+ $this->app->singleton(InstancePool::class, fn (Container $app): InstancePool => new InstancePool(
+ $app->make(InstanceResolverInterface::class)
+ ));
+
+ // One cache manager, shared by the engine and the doctor's cache check
+ $this->app->singleton(DiscoveryCacheManager::class, fn (Container $app): DiscoveryCacheManager => (new LaravelDiscoveryCacheManagerFactory(
+ $app,
+ $app->make(DebugDetectorInterface::class)
+ ))->make());
// Register ServiceProviderDiscovery so the DiscoveryRegistrar can find it
$this->app->singleton(ServiceProviderDiscovery::class);
@@ -90,11 +111,12 @@ private function registerOptimizedServices(): void
*/
private function registerDiscoveryEngine(): void
{
- $this->app->singleton(DiscoveryEngineInterface::class, fn ($app): DiscoveryEngine => new DiscoveryEngine(
- container: $app,
- debugDetector: $app->make(DebugDetectorInterface::class),
+ $this->app->singleton(DiscoveryEngineInterface::class, fn (Container $app): DiscoveryEngine => new DiscoveryEngine(
+ resolver: $app->make(InstanceResolverInterface::class),
reflectionCache: $app->make(ReflectionCacheInterface::class),
instancePool: $app->make(InstancePool::class),
+ cacheManager: $app->make(DiscoveryCacheManager::class),
+ registrar: new DiscoveryRegistrar($app),
skipClasses: (array) config('discovery.skip_classes', []),
skipPaths: (array) config('discovery.skip_paths', []),
));
@@ -105,7 +127,7 @@ private function registerDiscoveryEngine(): void
*/
private function registerDiscoveryManager(): void
{
- $this->app->singleton(DiscoveryManager::class, fn ($app): DiscoveryManager => new DiscoveryManager(
+ $this->app->singleton(DiscoveryManager::class, fn (Container $app): DiscoveryManager => new DiscoveryManager(
engine: $app->make(DiscoveryEngineInterface::class)
));
}
diff --git a/src/Discovery/Infrastructure/Services/DiscoveryCacheManager.php b/src/Discovery/Infrastructure/Services/DiscoveryCacheManager.php
deleted file mode 100644
index d1e98790..00000000
--- a/src/Discovery/Infrastructure/Services/DiscoveryCacheManager.php
+++ /dev/null
@@ -1,284 +0,0 @@
-
- */
- private static array $structuresCache = [];
-
- /**
- * Above this, one location's scan is reported rather than absorbed.
- */
- private const int SLOW_SCAN_MS = 250;
-
- /**
- * Resolved cache driver for Spatie's structure discovery.
- */
- private readonly ?DiscoverCacheDriver $cacheDriver;
-
- /**
- * Where each location's structures came from in this process, in order.
- *
- * @var list
- */
- private array $scans = [];
-
- public function __construct(
- private readonly Container $container,
- private readonly DebugDetectorInterface $debugDetector
- ) {
- $this->cacheDriver = $this->resolveCacheDriver();
- }
-
- /**
- * Get structures for a specific location, using static and Spatie cache.
- *
- * @param DiscoveryLocationInterface $location The discovery location
- * @param DiscoveryContext $context Context for recording cache stats
- * @return array The discovered structures
- */
- public function getStructuresForLocation(DiscoveryLocationInterface $location, DiscoveryContext $context): array
- {
- $cacheId = $this->generateCacheId($location);
-
- if (isset(self::$structuresCache[$cacheId])) {
- $context->recordCacheHit();
- $this->recordScan($location, 'memory', 0.0, count(self::$structuresCache[$cacheId]));
-
- return self::$structuresCache[$cacheId];
- }
-
- $context->recordCacheMiss();
-
- // Asked before reading: once the discoverer has run, a cold cache has
- // been filled and looks exactly like a warm one.
- $fromCache = $this->shouldUseCache() && $this->cacheDriver->has($cacheId);
-
- $discover = $this->createSpatieDiscoverer($location, $cacheId);
-
- $startedAt = microtime(true);
- $structures = $discover->get();
- $elapsed = (microtime(true) - $startedAt) * 1000;
-
- self::$structuresCache[$cacheId] = $structures;
- $this->recordScan($location, $fromCache ? 'cache' : 'disk', $elapsed, count($structures));
-
- $this->warnIfSlow($location, $elapsed, count($structures));
-
- return $structures;
- }
-
- /**
- * Where each location's structures came from, and what it cost.
- *
- * `memory` is this process's own copy, `cache` the persistent cache, `disk`
- * a real scan — the one that costs, and the only one in debug mode, where
- * the persistent cache is off. The context's hit and miss counters only
- * know about the first of the three.
- *
- * @return list
- */
- public function scans(): array
- {
- return $this->scans;
- }
-
- /**
- * @param 'memory'|'cache'|'disk' $source
- */
- private function recordScan(DiscoveryLocationInterface $location, string $source, float $milliseconds, int $structures): void
- {
- $this->scans[] = [
- 'path' => $location->getPath(),
- 'source' => $source,
- 'milliseconds' => round($milliseconds, 2),
- 'structures' => $structures,
- ];
- }
-
- /**
- * Say so when one location costs more than it can possibly be worth.
- *
- * Discovery walks a directory in full, and Symfony's Finder enumerates
- * before it filters by extension — so a `node_modules/` anywhere under a
- * location is paid for on every request that misses the cache, which in
- * debug mode is every request. Measured once: 69,741 files walked to reach
- * three PHP files, 1,691 ms against 1 ms, and nothing anywhere said so.
- *
- * The threshold is deliberately far above a healthy scan — the same site's
- * other four locations came in between 2 and 25 ms — so this stays quiet
- * until something is actually wrong. Debug only: the cache carries the
- * cost in production, and a log line per request is its own problem.
- */
- private function warnIfSlow(DiscoveryLocationInterface $location, float $elapsed, int $structures): void
- {
- if ($elapsed < self::SLOW_SCAN_MS || ! $this->debugDetector->isDebugMode()) {
- return;
- }
-
- try {
- $this->container->make(LoggerInterface::class)->warning(sprintf(
- 'Pollora discovery scanned %s in %dms for %d structure(s). '
- .'A directory this slow usually holds node_modules, vendor or a build output; '
- .'discovery walks every file in it on each request while the cache is off.',
- $location->getPath(),
- (int) round($elapsed),
- $structures
- ));
- } catch (\Throwable) {
- // A missing logger must not turn a warning into a failure.
- }
- }
-
- /**
- * Clear persistent Spatie cache for the given locations.
- *
- * @param iterable $locations
- */
- public function clearCache(iterable $locations): void
- {
- if (! $this->cacheDriver instanceof DiscoverCacheDriver) {
- return;
- }
-
- foreach ($locations as $location) {
- $cacheId = $this->generateCacheId($location);
- $this->cacheDriver->forget($cacheId);
- }
- }
-
- /**
- * Classes on disk that the cached entry for a location does not list.
- *
- * They stay invisible to discovery — no hook, no post type, no route — until
- * the cache is cleared, and nothing says so. Null when the location has no
- * cached entry, or the cache is off, since then discovery reads the disk.
- *
- * @return list|null
- */
- public function classesMissingFromCache(DiscoveryLocationInterface $location): ?array
- {
- if (! $this->shouldUseCache()) {
- return null;
- }
-
- $cacheId = $this->generateCacheId($location);
-
- if (! $this->cacheDriver->has($cacheId)) {
- return null;
- }
-
- $names = static fn (array $structures): array => array_map(
- static fn (mixed $structure): string => is_object($structure) && method_exists($structure, 'getFcqn') ? $structure->getFcqn() : (string) $structure,
- $structures,
- );
-
- return array_values(array_diff(
- $names(Discover::in($location->getPath())->full()->get()),
- $names($this->cacheDriver->get($cacheId)),
- ));
- }
-
- /**
- * Whether discovery reads through a persistent cache (it does not in debug mode).
- */
- public function isCacheEnabled(): bool
- {
- return $this->shouldUseCache();
- }
-
- /**
- * Get the cache driver instance.
- */
- public function getCacheDriver(): ?DiscoverCacheDriver
- {
- return $this->cacheDriver;
- }
-
- /**
- * Get the number of statically cached entries.
- */
- public function getStaticCacheSize(): int
- {
- return count(self::$structuresCache);
- }
-
- /**
- * Resolve the cache driver from Laravel configuration.
- */
- private function resolveCacheDriver(): ?DiscoverCacheDriver
- {
- if ($this->debugDetector->isDebugMode()) {
- return new NullDiscoverCacheDriver;
- }
-
- $cacheConfig = config('structure-discoverer.cache', []);
- $driverClass = $cacheConfig['driver'] ?? LaravelDiscoverCacheDriver::class;
- $store = $cacheConfig['store'] ?? null;
-
- if ($driverClass === LaravelDiscoverCacheDriver::class) {
- return new LaravelDiscoverCacheDriver(prefix: 'pollora', store: $store);
- }
-
- try {
- return $this->container->make($driverClass);
- } catch (\Throwable) {
- return new LaravelDiscoverCacheDriver(prefix: 'pollora', store: $store);
- }
- }
-
- /**
- * Create a configured Spatie discoverer instance.
- */
- private function createSpatieDiscoverer(DiscoveryLocationInterface $location, string $cacheId): Discover
- {
- $discover = Discover::in($location->getPath())->full();
-
- if ($this->shouldUseCache()) {
- return $discover->withCache($cacheId, $this->cacheDriver);
- }
-
- return $discover;
- }
-
- /**
- * Determine if caching should be used.
- */
- private function shouldUseCache(): bool
- {
- return $this->cacheDriver instanceof DiscoverCacheDriver
- && ! ($this->cacheDriver instanceof NullDiscoverCacheDriver);
- }
-
- /**
- * Generate cache ID for a discovery location.
- */
- private function generateCacheId(DiscoveryLocationInterface $location): string
- {
- return 'discovery_'.md5($location->getPath());
- }
-}
diff --git a/src/Discovery/Infrastructure/Services/DiscoveryEngine.php b/src/Discovery/Infrastructure/Services/DiscoveryEngine.php
deleted file mode 100644
index 6671206e..00000000
--- a/src/Discovery/Infrastructure/Services/DiscoveryEngine.php
+++ /dev/null
@@ -1,645 +0,0 @@
-
- */
- private Collection $locations;
-
- /**
- * Collection of registered discoveries
- *
- * @var Collection
- */
- private Collection $discoveries;
-
- /**
- * Discovery context for coordinating across discoveries
- */
- private readonly DiscoveryContext $context;
-
- /**
- * Instance pool for managing class instances
- */
- private readonly InstancePool $instancePool;
-
- /**
- * Cache manager for Spatie's structure discovery
- */
- private readonly DiscoveryCacheManager $cacheManager;
-
- /**
- * Registrar for auto-discovering Discovery classes
- */
- private readonly DiscoveryRegistrar $registrar;
-
- /**
- * Create a new discovery engine
- *
- * @param Container $container The service container for dependency injection
- * @param DebugDetectorInterface $debugDetector Debug mode detector
- * @param ReflectionCacheInterface|null $reflectionCache Optional reflection cache
- * @param InstancePool|null $instancePool Optional instance pool
- * @param LoggerInterface|null $logger Optional PSR-3 logger
- * @param DiscoveryCacheManager|null $cacheManager Optional cache manager
- * @param array $skipClasses Classes to exclude from discovery
- * @param array $skipPaths Path patterns to exclude from discovery
- */
- public function __construct(
- private readonly Container $container,
- DebugDetectorInterface $debugDetector,
- ?ReflectionCacheInterface $reflectionCache = null,
- ?InstancePool $instancePool = null,
- private readonly ?LoggerInterface $logger = null,
- ?DiscoveryCacheManager $cacheManager = null,
- private readonly array $skipClasses = [],
- private readonly array $skipPaths = [],
- ) {
- $this->locations = new Collection;
- $this->discoveries = new Collection;
-
- // Initialize optimized services
- $reflectionCache ??= new ReflectionCache($container);
- $this->instancePool = $instancePool ?? new InstancePool($container);
- $this->context = new DiscoveryContext($reflectionCache);
- $this->cacheManager = $cacheManager ?? new DiscoveryCacheManager($container, $debugDetector);
- $this->registrar = new DiscoveryRegistrar($container, $this->logger);
- }
-
- /**
- * {@inheritDoc}
- */
- public function addLocation(DiscoveryLocationInterface $location): static
- {
- // Check for duplicate locations (same path)
- $locationPath = realpath($location->getPath()) ?: $location->getPath();
-
- $isDuplicate = $this->locations->contains(function (DiscoveryLocationInterface $existingLocation) use ($locationPath): bool {
- $existingPath = realpath($existingLocation->getPath()) ?: $existingLocation->getPath();
-
- return $existingPath === $locationPath;
- });
-
- if (! $isDuplicate) {
- $this->locations->push($location);
- }
-
- return $this;
- }
-
- /**
- * {@inheritDoc}
- */
- public function addLocations(array $locations): static
- {
- foreach ($locations as $location) {
- $this->addLocation($location);
- }
-
- return $this;
- }
-
- /**
- * {@inheritDoc}
- */
- public function addDiscovery(string $identifier, string|DiscoveryInterface $discovery): static
- {
- if ($this->discoveries->has($identifier)) {
- throw InvalidDiscoveryException::duplicateIdentifier($identifier);
- }
-
- $discoveryInstance = $this->resolveDiscovery($discovery);
- $this->discoveries->put($identifier, $discoveryInstance);
-
- return $this;
- }
-
- /**
- * {@inheritDoc}
- */
- public function addDiscoveries(array $discoveries): static
- {
- foreach ($discoveries as $identifier => $discovery) {
- $this->addDiscovery($identifier, $discovery);
- }
-
- return $this;
- }
-
- /**
- * {@inheritDoc}
- */
- public function discover(): static
- {
- // Auto-register Discovery classes from container bindings
- $this->registrar->registerFromContainer($this);
-
- $allStructures = $this->discoverAllStructures();
-
- $this->processStructuresUnified($allStructures);
-
- return $this;
- }
-
- /**
- * {@inheritDoc}
- */
- public function apply(): static
- {
- foreach ($this->discoveries as $discovery) {
- try {
- // Inject instance pool into discoveries that can use it
- $this->injectInstancePoolIfSupported($discovery);
-
- $this->applyPending($discovery);
- } catch (\Throwable $e) {
- throw DiscoveryException::applicationFailed($discovery::class, $e);
- }
- }
-
- return $this;
- }
-
- /**
- * {@inheritDoc}
- */
- public function run(): static
- {
- return $this->discover()->apply();
- }
-
- /**
- * {@inheritDoc}
- */
- public function getDiscovery(string $identifier): DiscoveryInterface
- {
- if (! $this->discoveries->has($identifier)) {
- throw DiscoveryNotFoundException::withIdentifier($identifier);
- }
-
- return $this->discoveries->get($identifier);
- }
-
- /**
- * {@inheritDoc}
- */
- public function getDiscoveries(): Collection
- {
- return $this->discoveries;
- }
-
- /**
- * {@inheritDoc}
- */
- public function getLocations(): Collection
- {
- return $this->locations;
- }
-
- /**
- * Discover items for a single discovery class
- *
- * @param DiscoveryInterface $discovery The discovery instance
- *
- * @throws DiscoveryException When discovery fails
- */
- private function discoverSingle(DiscoveryInterface $discovery): void
- {
- try {
- // Initialize fresh discovery items
- $discovery->setItems(new DiscoveryItems);
-
- // Discover PHP structures using Spatie's native cache
- $this->discoverStructures($discovery);
- } catch (\Throwable $throwable) {
- $this->logDiscoveryError($discovery::class, $throwable);
- throw DiscoveryException::discoveryFailed($discovery::class, $throwable);
- }
- }
-
- /**
- * Discover PHP structures using Spatie's discoverer
- *
- * @param DiscoveryInterface $discovery The discovery instance
- */
- private function discoverStructures(DiscoveryInterface $discovery): void
- {
- $reflectionCache = $this->context->getReflectionCache();
-
- foreach ($this->locations as $location) {
- $structures = $this->cacheManager->getStructuresForLocation($location, $this->context);
-
- foreach ($structures as $structure) {
- $discovery->discover($location, $structure, $reflectionCache);
- }
- }
- }
-
- /**
- * Clear all locations
- */
- public function clearLocations(): static
- {
- $this->locations = new Collection;
-
- return $this;
- }
-
- /**
- * Clear persistent discovery cache
- *
- * Only clears persistent cache (Spatie's structure discoverer cache).
- * In-memory caches (reflection, instance pool, static cache) are automatically
- * cleared at the end of the PHP process and don't need manual clearing.
- */
- public function clearCache(): static
- {
- $this->cacheManager->clearCache($this->locations);
-
- return $this;
- }
-
- /**
- * Run a specific discovery
- *
- * @param string $identifier The discovery identifier
- * @param DiscoveryInterface $discovery The discovery instance
- */
- public function runDiscovery(string $identifier, DiscoveryInterface $discovery): static
- {
- try {
- $this->discoverSingle($discovery);
- $this->applyPending($discovery);
- } catch (\Throwable $throwable) {
- $this->logDiscoveryError($discovery::class, $throwable);
- throw DiscoveryException::discoveryFailed($discovery::class, $throwable);
- }
-
- return $this;
- }
-
- /**
- * Apply only the items the discovery has not applied yet.
- *
- * Discoveries are shared by every engine (the main one, its clones for
- * modules) and their items accumulate across locations, so the engine is
- * asked to apply several times per request. Handing the discovery only its
- * new items makes each item apply once, whether or not the discovery's
- * apply() is idempotent. Its full item list is restored afterwards, for
- * whatever reads it later (status, doctor, dashboard).
- *
- * @param DiscoveryInterface $discovery The discovery to apply
- */
- private function applyPending(DiscoveryInterface $discovery): void
- {
- $items = $discovery->getItems();
- $pending = $items->pending();
-
- if (! $pending->isLoaded()) {
- return;
- }
-
- $discovery->setItems($pending);
-
- try {
- $discovery->apply();
- } finally {
- $items->markApplied();
- $discovery->setItems($items);
- }
- }
-
- /**
- * Clone the engine
- */
- public function __clone(): void
- {
- $this->locations = clone $this->locations;
- $this->discoveries = clone $this->discoveries;
- }
-
- /**
- * Resolve a discovery instance from class name or instance
- *
- * @param string|DiscoveryInterface $discovery The discovery to resolve
- * @return DiscoveryInterface The resolved discovery instance
- *
- * @throws InvalidDiscoveryException When the discovery is invalid
- */
- private function resolveDiscovery(string|DiscoveryInterface $discovery): DiscoveryInterface
- {
- if ($discovery instanceof DiscoveryInterface) {
- return $discovery;
- }
-
- if (! class_exists($discovery)) {
- throw InvalidDiscoveryException::invalidClass($discovery, 'Class does not exist');
- }
-
- if (! is_subclass_of($discovery, DiscoveryInterface::class)) {
- throw InvalidDiscoveryException::missingInterface($discovery, DiscoveryInterface::class);
- }
-
- try {
- return $this->container->make($discovery);
- } catch (\Throwable $throwable) {
- throw InvalidDiscoveryException::invalidClass($discovery, 'Cannot instantiate: '.$throwable->getMessage());
- }
- }
-
- /**
- * Discover all structures from all locations using Spatie's discoverer.
- *
- * @return array All discovered structures with their locations
- */
- private function discoverAllStructures(): array
- {
- $allStructures = [];
-
- foreach ($this->locations as $location) {
- $locationStructures = $this->cacheManager->getStructuresForLocation($location, $this->context);
-
- foreach ($locationStructures as $structure) {
- $allStructures[] = [
- 'structure' => $structure,
- 'location' => $location,
- ];
- }
- }
-
- return $allStructures;
- }
-
- /**
- * Process structures using unified approach to minimize redundant operations.
- *
- * @param array $structures All discovered structures with their locations
- */
- private function processStructuresUnified(array $structures): void
- {
- // Group structures by class name for batch processing
- $structuresByClass = [];
-
- foreach ($structures as $entry) {
- $structure = $entry['structure'];
-
- // Concrete classes, and enums carrying attributes (#[CapabilitySet]…).
- $isDiscoverable = ($structure instanceof DiscoveredClass && ! $structure->isAbstract)
- || ($structure instanceof DiscoveredEnum && $structure->attributes !== []);
-
- if (! $isDiscoverable) {
- continue;
- }
-
- $className = $structure->namespace.'\\'.$structure->name;
-
- // Config-level exclusions (no reflection needed)
- if ($this->isSkippedByConfig($className, $structure->file)) {
- continue;
- }
-
- // Attribute-level exclusion
- $skipAttr = $this->getSkipDiscoveryAttribute($structure);
- if ($skipAttr instanceof SkipDiscovery && $skipAttr->except === []) {
- continue;
- }
-
- $structuresByClass[$className] = [
- 'structure' => $structure,
- 'location' => $entry['location'],
- 'skip_except' => $skipAttr?->except,
- ];
- }
-
- // Initialize discoveries with fresh items only if they don't have items yet
- foreach ($this->discoveries as $discovery) {
- if (! $discovery->getItems()->isLoaded()) {
- $discovery->setItems(new DiscoveryItems);
- }
- }
-
- // Process each class once for all applicable discoveries
- foreach ($structuresByClass as $className => $data) {
- $this->processClassForAllDiscoveries(
- $data['structure'],
- $data['location'],
- $className,
- $data['skip_except']
- );
- }
-
- $this->context->incrementStat('classes_processed', count($structuresByClass));
- }
-
- /**
- * Process a single class for all applicable discoveries.
- *
- * @param DiscoveredClass|DiscoveredEnum $structure The discovered structure
- * @param DiscoveryLocationInterface $location The discovery location
- * @param string $className The fully qualified class name
- */
- /**
- * @param array|null $skipExcept If set, only these discovery classes may process the structure
- */
- private function processClassForAllDiscoveries(
- DiscoveredClass|DiscoveredEnum $structure,
- DiscoveryLocationInterface $location,
- string $className,
- ?array $skipExcept = null
- ): void {
- try {
- $reflectionCache = $this->context->getReflectionCache();
-
- foreach ($this->discoveries as $discoveryId => $discovery) {
- try {
- if ($this->context->isProcessed($className, $discoveryId)) {
- continue;
- }
-
- // #[SkipDiscovery(except: [...])] — only allow listed discoveries
- if ($skipExcept !== null && ! in_array($discovery::class, $skipExcept, true)) {
- continue;
- }
-
- $discovery->discover($location, $structure, $reflectionCache);
-
- $this->context->markProcessed($className, $discoveryId);
- $this->context->incrementStat('discoveries_executed');
-
- } catch (\Throwable $e) {
- $this->context->recordError();
- $this->logDiscoveryError(sprintf('Discovery %s for class %s', $discoveryId, $className), $e);
- }
- }
-
- } catch (\Throwable $throwable) {
- $this->context->recordError();
- $this->logDiscoveryError('Failed to process class '.$className, $throwable);
- }
- }
-
- /**
- * Check if a class or file is excluded by config-level skip rules.
- */
- private function isSkippedByConfig(string $className, string $filePath): bool
- {
- if (in_array($className, $this->skipClasses, true)) {
- return true;
- }
-
- foreach ($this->skipPaths as $pattern) {
- if (str_contains($filePath, $pattern)) {
- return true;
- }
- }
-
- return false;
- }
-
- /**
- * Get the #[SkipDiscovery] attribute instance if present on the structure.
- *
- * Uses Spatie's token-parsed data to detect the attribute without reflection.
- * Only loads reflection to read parameters when the attribute is found.
- */
- private function getSkipDiscoveryAttribute(DiscoveredClass|DiscoveredEnum $structure): ?SkipDiscovery
- {
- foreach ($structure->attributes as $attribute) {
- if ($attribute->class === SkipDiscovery::class) {
- return $this->instantiateSkipDiscovery($structure);
- }
- }
-
- return null;
- }
-
- /**
- * Instantiate the #[SkipDiscovery] attribute to read its parameters.
- */
- private function instantiateSkipDiscovery(DiscoveredClass|DiscoveredEnum $structure): SkipDiscovery
- {
- try {
- $className = $structure->namespace.'\\'.$structure->name;
- $reflection = new \ReflectionClass($className);
- $attrs = $reflection->getAttributes(SkipDiscovery::class);
-
- if ($attrs !== []) {
- return $attrs[0]->newInstance();
- }
- } catch (\Throwable) {
- // If reflection fails, treat as full skip
- }
-
- return new SkipDiscovery;
- }
-
- /**
- * Get the discovery context.
- */
- public function getContext(): DiscoveryContext
- {
- return $this->context;
- }
-
- /**
- * Get the instance pool.
- */
- public function getInstancePool(): InstancePool
- {
- return $this->instancePool;
- }
-
- /**
- * Get performance statistics.
- *
- * @return array
- */
- public function getPerformanceStats(): array
- {
- return [
- 'context' => $this->context->getSummary(),
- 'instance_pool' => $this->instancePool->getStats(),
- 'static_cache_size' => $this->cacheManager->getStaticCacheSize(),
- ];
- }
-
- /**
- * Inject instance pool into discoveries that support it
- *
- * @param DiscoveryInterface $discovery The discovery to potentially inject into
- */
- private function injectInstancePoolIfSupported(DiscoveryInterface $discovery): void
- {
- // Check if the discovery has a method to accept the instance pool
- if (method_exists($discovery, 'setInstancePool')) {
- $discovery->setInstancePool($this->instancePool);
- }
- }
-
- /**
- * Get the cache driver instance
- */
- public function getCacheDriver(): ?DiscoverCacheDriver
- {
- return $this->cacheManager->getCacheDriver();
- }
-
- /**
- * Get the cache manager instance
- */
- public function getCacheManager(): DiscoveryCacheManager
- {
- return $this->cacheManager;
- }
-
- /**
- * Log discovery errors with consistent format
- *
- * @param string $context The error context
- * @param \Throwable $exception The exception
- */
- private function logDiscoveryError(string $context, \Throwable $exception): void
- {
- $this->logger?->error(sprintf('%s: %s', $context, $exception->getMessage()), [
- 'exception' => $exception,
- ]);
- }
-}
diff --git a/src/Discovery/Infrastructure/Services/DiscoveryRegistrar.php b/src/Discovery/Infrastructure/Services/DiscoveryRegistrar.php
index 07c0f7db..40763f2b 100644
--- a/src/Discovery/Infrastructure/Services/DiscoveryRegistrar.php
+++ b/src/Discovery/Infrastructure/Services/DiscoveryRegistrar.php
@@ -7,6 +7,7 @@
use Illuminate\Container\Container;
use Pollora\Discovery\Domain\Contracts\DiscoveryEngineInterface;
use Pollora\Discovery\Domain\Contracts\DiscoveryInterface;
+use Pollora\Discovery\Domain\Contracts\DiscoveryRegistrarInterface;
use Psr\Log\LoggerInterface;
/**
@@ -19,7 +20,7 @@
* Each ServiceProvider still registers its Discovery as a singleton in
* register(). The registrar picks them up and adds them to the engine.
*/
-final readonly class DiscoveryRegistrar
+final readonly class DiscoveryRegistrar implements DiscoveryRegistrarInterface
{
public function __construct(
private Container $container,
@@ -30,9 +31,9 @@ public function __construct(
* Scan container bindings and register any DiscoveryInterface implementations.
*
* @param DiscoveryEngineInterface $engine The engine to register discoveries with
- * @return array Identifiers of auto-registered discoveries
+ * @return array Identifiers of auto-registered discoveries
*/
- public function registerFromContainer(DiscoveryEngineInterface $engine): array
+ public function registerInto(DiscoveryEngineInterface $engine): array
{
$registered = [];
diff --git a/src/Discovery/Infrastructure/Services/InstancePool.php b/src/Discovery/Infrastructure/Services/InstancePool.php
deleted file mode 100644
index de66a871..00000000
--- a/src/Discovery/Infrastructure/Services/InstancePool.php
+++ /dev/null
@@ -1,311 +0,0 @@
-
- */
- private array $instances = [];
-
- /**
- * Currently instantiating classes (for circular dependency detection)
- *
- * @var array
- */
- private array $instantiating = [];
-
- /**
- * Instance creation statistics
- *
- * @var array
- */
- private array $stats = [
- 'hits' => 0,
- 'misses' => 0,
- 'circular_dependencies' => 0,
- 'errors' => 0,
- ];
-
- /**
- * DI Container for instance resolution
- */
- private readonly Container $container;
-
- public function __construct(?Container $container = null)
- {
- $this->container = $container ?? app();
- }
-
- /**
- * Get or create an instance of the specified class.
- *
- * @param string $className The fully qualified class name
- * @return object The class instance
- *
- * @throws CircularDependencyException If a circular dependency is detected
- * @throws \RuntimeException If the class cannot be instantiated
- */
- public function getInstance(string $className): object
- {
- // Cache hit - return existing instance
- if (isset($this->instances[$className])) {
- $this->stats['hits']++;
-
- return $this->instances[$className];
- }
-
- // Cache miss - need to create instance
- $this->stats['misses']++;
-
- // Detect circular dependencies
- if (isset($this->instantiating[$className])) {
- $this->stats['circular_dependencies']++;
- throw new CircularDependencyException(
- 'Circular dependency detected while instantiating: '.$className
- );
- }
-
- try {
- // Mark as currently instantiating
- $this->instantiating[$className] = true;
-
- // Create instance through DI container
- $instance = $this->container->make($className);
-
- // Store in pool
- $this->instances[$className] = $instance;
-
- // Remove from instantiating list
- unset($this->instantiating[$className]);
-
- return $instance;
-
- } catch (\Throwable $throwable) {
- // Clean up instantiating state on error
- unset($this->instantiating[$className]);
-
- $this->stats['errors']++;
-
- throw new \RuntimeException(
- sprintf('Failed to instantiate class %s: %s', $className, $throwable->getMessage()),
- $throwable->getCode(),
- $throwable
- );
- }
- }
-
- /**
- * Check if an instance exists in the pool.
- *
- * @param string $className The fully qualified class name
- * @return bool True if the instance exists in the pool
- */
- public function hasInstance(string $className): bool
- {
- return isset($this->instances[$className]);
- }
-
- /**
- * Preload multiple instances into the pool.
- *
- * @param array $classNames Array of class names to preload
- * @return array Map of successfully preloaded instances
- */
- public function preloadInstances(array $classNames): array
- {
- $preloadedInstances = [];
-
- foreach ($classNames as $className) {
- try {
- $preloadedInstances[$className] = $this->getInstance($className);
- } catch (\Throwable) {
- // Skip classes that cannot be instantiated
- continue;
- }
- }
-
- return $preloadedInstances;
- }
-
- /**
- * Remove an instance from the pool.
- *
- * @param string $className The class name to remove
- * @return bool True if the instance was removed, false if it didn't exist
- */
- public function removeInstance(string $className): bool
- {
- if (isset($this->instances[$className])) {
- unset($this->instances[$className]);
-
- return true;
- }
-
- return false;
- }
-
- /**
- * Get the number of instances in the pool.
- *
- * @return int The number of pooled instances
- */
- public function getPoolSize(): int
- {
- return count($this->instances);
- }
-
- /**
- * Get pool statistics.
- *
- * @return array Pool usage statistics
- */
- public function getStats(): array
- {
- $total = $this->stats['hits'] + $this->stats['misses'];
- $hitRatio = $total > 0 ? round(($this->stats['hits'] / $total) * 100, 2) : 0.0;
-
- return [
- 'pool_size' => $this->getPoolSize(),
- 'cache_hits' => $this->stats['hits'],
- 'cache_misses' => $this->stats['misses'],
- 'hit_ratio_percent' => $hitRatio,
- 'circular_dependencies' => $this->stats['circular_dependencies'],
- 'instantiation_errors' => $this->stats['errors'],
- 'total_requests' => $total,
- ];
- }
-
- /**
- * Get list of all pooled class names.
- *
- * @return array Array of pooled class names
- */
- public function getPooledClasses(): array
- {
- return array_keys($this->instances);
- }
-
- /**
- * Perform memory optimization by removing unused instances.
- *
- * @param int $maxPoolSize Maximum number of instances to keep
- */
- public function optimize(int $maxPoolSize = 100): void
- {
- if ($this->getPoolSize() <= $maxPoolSize) {
- return;
- }
-
- // Simple LRU-style cleanup - remove excess instances
- $excessCount = $this->getPoolSize() - $maxPoolSize;
- $classNames = array_keys($this->instances);
-
- for ($i = 0; $i < $excessCount; $i++) {
- if (isset($classNames[$i])) {
- unset($this->instances[$classNames[$i]]);
- }
- }
- }
-
- /**
- * Reset statistics counters.
- */
- public function resetStats(): void
- {
- $this->stats = [
- 'hits' => 0,
- 'misses' => 0,
- 'circular_dependencies' => 0,
- 'errors' => 0,
- ];
- }
-
- /**
- * Check if a class is currently being instantiated (circular dependency check).
- *
- * @param string $className The class name to check
- * @return bool True if the class is currently being instantiated
- */
- public function isInstantiating(string $className): bool
- {
- return isset($this->instantiating[$className]);
- }
-
- /**
- * Get debug information about the instance pool state.
- *
- * @return array Debug information
- */
- public function getDebugInfo(): array
- {
- return [
- 'instances' => $this->getPooledClasses(),
- 'instantiating' => array_keys($this->instantiating),
- 'stats' => $this->getStats(),
- 'memory_usage' => $this->estimateMemoryUsage(),
- ];
- }
-
- /**
- * Estimate memory usage of the instance pool.
- *
- * @return string Human-readable memory usage estimate
- */
- private function estimateMemoryUsage(): string
- {
- $memoryUsage = 0;
-
- foreach ($this->instances as $instance) {
- // Rough estimate - this is not precise but gives an idea
- $memoryUsage += strlen(serialize($instance));
- }
-
- return $this->formatBytes($memoryUsage);
- }
-
- /**
- * Format bytes into human-readable format.
- *
- * @param int $bytes Number of bytes
- * @return string Formatted string
- */
- private function formatBytes(int $bytes): string
- {
- $units = ['B', 'KB', 'MB', 'GB'];
- $unitIndex = 0;
-
- while ($bytes >= 1024 && $unitIndex < count($units) - 1) {
- $bytes /= 1024;
- $unitIndex++;
- }
-
- return round($bytes, 2).' '.$units[$unitIndex];
- }
-}
-
-/**
- * Exception thrown when circular dependency is detected during instantiation
- */
-class CircularDependencyException extends \RuntimeException {}
diff --git a/src/Discovery/Infrastructure/Services/ReflectionCache.php b/src/Discovery/Infrastructure/Services/ReflectionCache.php
deleted file mode 100644
index 6e8b3a25..00000000
--- a/src/Discovery/Infrastructure/Services/ReflectionCache.php
+++ /dev/null
@@ -1,255 +0,0 @@
-
- */
- private array $classReflections = [];
-
- /**
- * Cache for class-level attributes
- *
- * @var array>>
- */
- private array $classAttributes = [];
-
- /**
- * Cache for method-level attributes grouped by class and attribute type
- *
- * @var array>>
- */
- private array $methodsWithAttributes = [];
-
- /**
- * Cache for public methods per class
- *
- * @var array>
- */
- private array $publicMethods = [];
-
- /**
- * Cache for class instances from DI container
- *
- * @var array
- */
- private array $classInstances = [];
-
- /**
- * Cache for processed classes to avoid reprocessing
- *
- * @var array
- */
- private array $processedClasses = [];
-
- /**
- * DI Container for instance resolution
- */
- private readonly Container $container;
-
- public function __construct(?Container $container = null)
- {
- $this->container = $container ?? app();
- }
-
- /**
- * {@inheritDoc}
- */
- public function getClassReflection(string $className): ReflectionClass
- {
- if (! isset($this->classReflections[$className])) {
- try {
- // Check if class exists and can be autoloaded first
- if (! class_exists($className, true)) {
- throw new ReflectionException(sprintf('Class %s does not exist or cannot be autoloaded', $className));
- }
-
- $this->classReflections[$className] = new ReflectionClass($className);
- $this->processedClasses[$className] = true;
- } catch (\Throwable $e) {
- // Log the error but mark as processed to avoid retrying
- $this->processedClasses[$className] = true;
-
- throw new ReflectionException(
- sprintf('Failed to create reflection for class %s: %s', $className, $e->getMessage()),
- $e->getCode(),
- $e
- );
- }
- }
-
- return $this->classReflections[$className];
- }
-
- /**
- * {@inheritDoc}
- */
- public function getPublicMethods(string $className): array
- {
- $this->publicMethods[$className] ??= $this->getClassReflection($className)
- ->getMethods(ReflectionMethod::IS_PUBLIC);
-
- return $this->publicMethods[$className];
- }
-
- /**
- * {@inheritDoc}
- */
- public function getMethodsWithAttributes(string $className, ?string $attributeClass = null): array
- {
- $cacheKey = $attributeClass ?? '*';
-
- if (! isset($this->methodsWithAttributes[$className][$cacheKey])) {
- $this->buildMethodAttributeCache($className, $attributeClass);
- }
-
- return $this->methodsWithAttributes[$className][$cacheKey];
- }
-
- /**
- * {@inheritDoc}
- */
- public function getClassAttributes(string $className, ?string $attributeClass = null): array
- {
- $cacheKey = $attributeClass ?? '*';
-
- if (! isset($this->classAttributes[$className][$cacheKey])) {
- $this->buildClassAttributeCache($className, $attributeClass);
- }
-
- return $this->classAttributes[$className][$cacheKey];
- }
-
- /**
- * {@inheritDoc}
- */
- public function getClassInstance(string $className): object
- {
- if (! isset($this->classInstances[$className])) {
- try {
- // Validate the class can be instantiated before attempting
- $reflection = $this->getClassReflection($className);
-
- if ($reflection->isAbstract() || $reflection->isInterface() || $reflection->isTrait()) {
- throw new \InvalidArgumentException(
- 'Cannot instantiate abstract class, interface, or trait: '.$className
- );
- }
-
- if (! $reflection->isInstantiable()) {
- throw new \InvalidArgumentException(sprintf('Class %s is not instantiable', $className));
- }
-
- $this->classInstances[$className] = $this->container->make($className);
- } catch (\Throwable $e) {
- throw new \RuntimeException(
- sprintf('Failed to instantiate class %s: %s', $className, $e->getMessage()),
- $e->getCode(),
- $e
- );
- }
- }
-
- return $this->classInstances[$className];
- }
-
- /**
- * {@inheritDoc}
- */
- public function hasClass(string $className): bool
- {
- return isset($this->processedClasses[$className]);
- }
-
- /**
- * {@inheritDoc}
- */
- public function preloadClasses(array $classNames): void
- {
- foreach ($classNames as $className) {
- if (! $this->hasClass($className)) {
- try {
- $this->getClassReflection($className);
- } catch (\Throwable) {
- // Skip classes that cannot be loaded
- continue;
- }
- }
- }
- }
-
- /**
- * Build method attribute cache for a specific class and attribute type.
- *
- * @param string $className The class name to process
- * @param string|null $attributeClass Optional attribute class filter
- */
- private function buildMethodAttributeCache(string $className, ?string $attributeClass = null): void
- {
- $cacheKey = $attributeClass ?? '*';
- $methodsWithAttributes = [];
-
- foreach ($this->getPublicMethods($className) as $method) {
- if ($attributeClass === null) {
- // Get all methods with any attributes
- $hasMatchingAttribute = ! empty($method->getAttributes());
- } else {
- // Get methods with specific attribute class
- $hasMatchingAttribute = ! empty($method->getAttributes($attributeClass));
- }
-
- if ($hasMatchingAttribute) {
- $methodsWithAttributes[] = $method;
- }
- }
-
- $this->methodsWithAttributes[$className][$cacheKey] = $methodsWithAttributes;
- }
-
- /**
- * Build class attribute cache for a specific class and attribute type.
- *
- * @param string $className The class name to process
- * @param string|null $attributeClass Optional attribute class filter
- */
- private function buildClassAttributeCache(string $className, ?string $attributeClass = null): void
- {
- $cacheKey = $attributeClass ?? '*';
- $reflection = $this->getClassReflection($className);
-
- if ($attributeClass === null) {
- // Get all class attributes
- $attributes = $reflection->getAttributes();
- } else {
- // Get specific class attributes
- $attributes = $reflection->getAttributes($attributeClass);
- }
-
- $this->classAttributes[$className][$cacheKey] = $attributes;
- }
-}
diff --git a/src/Hook/Infrastructure/Checks/AsyncActionsCheck.php b/src/Hook/Infrastructure/Checks/AsyncActionsCheck.php
index 1ce6440f..11857797 100644
--- a/src/Hook/Infrastructure/Checks/AsyncActionsCheck.php
+++ b/src/Hook/Infrastructure/Checks/AsyncActionsCheck.php
@@ -179,10 +179,6 @@ private function connectionProblems(): array
*/
private function cronFix(): string
{
- if ($this->config->get('wordpress.use_laravel_scheduler')) {
- return 'WP-Cron events are Laravel jobs here (wordpress.use_laravel_scheduler): run a queue worker, php artisan queue:work';
- }
-
return $this->inspector->cronRunsOnPageLoad()
? 'The site gets too few visits to run WP-Cron: add a system cron that requests wp-cron.php every minute'
: 'DISABLE_WP_CRON is set (Pollora sets it): add a system cron, e.g. * * * * * curl -s https://example.com/cms/wp-cron.php, or wp cron event run --due-now';
diff --git a/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php b/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php
index 705cbe79..42efea50 100644
--- a/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php
+++ b/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php
@@ -49,6 +49,9 @@ public function boot(): void
$this->app->tag([AsyncActionsCheck::class], DoctorServiceProvider::CHECKS_TAG);
$this->configureAsync($this->app->make(Repository::class));
+
+ // The run of a queued action must not wait for an Action to be built in the request: the request that runs it may register no hook at all
+ Async::listen();
}
private function configureAsync(Repository $config): void
diff --git a/src/Hook/Infrastructure/Services/AsyncInspector.php b/src/Hook/Infrastructure/Services/AsyncInspector.php
index da55592e..d05ecf3b 100644
--- a/src/Hook/Infrastructure/Services/AsyncInspector.php
+++ b/src/Hook/Infrastructure/Services/AsyncInspector.php
@@ -81,8 +81,9 @@ public function overdueCronEvents(int $seconds): array
continue;
}
+ // The events another driver keeps (Action Scheduler) are in the index too: this counts what only WP-Cron runs
foreach ((array) $hooks as $events) {
- $count += count((array) $events);
+ $count += count(array_filter((array) $events, static fn (mixed $event): bool => ! is_array($event) || ($event['driver'] ?? 'wp-cron') === 'wp-cron'));
}
$oldest = $oldest === null ? $timestamp : min($oldest, $timestamp);
diff --git a/src/Meta/Domain/Enums/Control.php b/src/Meta/Domain/Enums/Control.php
deleted file mode 100644
index ba3e0600..00000000
--- a/src/Meta/Domain/Enums/Control.php
+++ /dev/null
@@ -1,25 +0,0 @@
-attributeArgs[$key] ?? $default;
}
+ /**
+ * Replace every attribute argument.
+ *
+ * @param array $args The arguments to keep
+ */
+ public function replaceArgs(array $args): void
+ {
+ $this->attributeArgs = $args;
+ }
+
/**
* Merge additional arguments into the configuration.
*
diff --git a/src/PostType/Infrastructure/Services/PostTypeDiscovery.php b/src/PostType/Infrastructure/Services/PostTypeDiscovery.php
index 0095dd91..de149d40 100644
--- a/src/PostType/Infrastructure/Services/PostTypeDiscovery.php
+++ b/src/PostType/Infrastructure/Services/PostTypeDiscovery.php
@@ -5,6 +5,9 @@
namespace Pollora\PostType\Infrastructure\Services;
use Illuminate\Support\Str;
+use Pollora\Attributes\Contracts\ConfiguresArguments;
+use Pollora\Attributes\Contracts\ConfiguresArgumentsWithMethod;
+use Pollora\Attributes\Contracts\HandlesAttributes;
use Pollora\Attributes\PostType;
use Pollora\Discovery\Domain\Contracts\ConfigurableDiscoveryInterface;
use Pollora\Discovery\Domain\Contracts\DiscoveryInterface;
@@ -302,8 +305,13 @@ private function processClassAttribute(ReflectionClass $reflectionClass, \Reflec
{
$attributeInstance = $attribute->newInstance();
- // Check if the attribute has a handle method and call it
- if (method_exists($attributeInstance, 'handle')) {
+ if ($attributeInstance instanceof ConfiguresArguments) {
+ $config->replaceArgs($attributeInstance->configureArguments($config->withArgs()));
+
+ return;
+ }
+
+ if ($attributeInstance instanceof HandlesAttributes) {
$attributeInstance->handle(app(), $config, $reflectionClass, $attributeInstance);
}
}
@@ -325,8 +333,13 @@ private function processMethodAttribute(
): void {
$attributeInstance = $attribute->newInstance();
- // Check if the attribute has a handle method and call it
- if (method_exists($attributeInstance, 'handle')) {
+ if ($attributeInstance instanceof ConfiguresArgumentsWithMethod) {
+ $config->replaceArgs($attributeInstance->configureArguments($config->withArgs(), [new $method->class, $method->getName()]));
+
+ return;
+ }
+
+ if ($attributeInstance instanceof HandlesAttributes) {
$attributeInstance->handle(app(), $config, $method, $attributeInstance);
}
}
diff --git a/src/Providers/PolloraServiceProvider.php b/src/Providers/PolloraServiceProvider.php
index b83b4b7a..efb6d705 100755
--- a/src/Providers/PolloraServiceProvider.php
+++ b/src/Providers/PolloraServiceProvider.php
@@ -4,7 +4,6 @@
namespace Pollora\Providers;
-use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\ServiceProvider;
use Log1x\SageDirectives\SageDirectivesServiceProvider;
use Pollora\Ability\Infrastructure\Providers\AbilityServiceProvider;
@@ -36,7 +35,6 @@
use Pollora\PostType\Infrastructure\Providers\PostTypeServiceProvider;
use Pollora\Role\Infrastructure\Providers\RoleServiceProvider;
use Pollora\Route\Infrastructure\Providers\RouteServiceProvider;
-use Pollora\Schedule\Jobs\JobDispatcher;
use Pollora\Schedule\SchedulerServiceProvider;
use Pollora\Taxonomy\Infrastructure\Providers\TaxonomyServiceProvider;
use Pollora\Theme\Infrastructure\Providers\ThemeServiceProvider;
@@ -125,7 +123,6 @@ public function register(): void
$this->app->register(RouteServiceProvider::class);
$this->app->register(SchedulerServiceProvider::class);
- $this->app->singleton(JobDispatcher::class, fn ($app): JobDispatcher => new JobDispatcher($app->make(Dispatcher::class)));
// Authentication service provider
$this->app->register(AuthServiceProvider::class);
diff --git a/src/Role/Domain/Enums/Access.php b/src/Role/Domain/Enums/Access.php
deleted file mode 100644
index 71e4eb83..00000000
--- a/src/Role/Domain/Enums/Access.php
+++ /dev/null
@@ -1,40 +0,0 @@
-
- */
- public function genericCapabilities(): array
- {
- $contributor = ['edit_posts', 'delete_posts'];
- $author = ['publish_posts', 'edit_published_posts', 'delete_published_posts'];
- $editor = ['edit_others_posts', 'delete_others_posts', 'read_private_posts', 'edit_private_posts', 'delete_private_posts'];
-
- return match ($this) {
- self::Contributor => $contributor,
- self::Author => [...$contributor, ...$author],
- self::Editor => [...$contributor, ...$author, ...$editor],
- };
- }
-}
diff --git a/src/Schedule/Application/Services/ScheduleStatus.php b/src/Schedule/Application/Services/ScheduleStatus.php
new file mode 100644
index 00000000..e6a4bb7b
--- /dev/null
+++ b/src/Schedule/Application/Services/ScheduleStatus.php
@@ -0,0 +1,125 @@
+config->get('schedule.driver', CronIndex::WP_CRON);
+
+ return is_string($driver) && $driver !== '' ? $driver : CronIndex::WP_CRON;
+ }
+
+ /**
+ * The driver new events go to now.
+ */
+ public function active(): string
+ {
+ return Schedule::engine()->driverName();
+ }
+
+ /**
+ * Whether the configured driver is one the package does not know: the site runs on WP-Cron instead.
+ */
+ public function unknown(): bool
+ {
+ return ! Schedule::knows($this->configured());
+ }
+
+ /**
+ * Whether the driver is not there (Action Scheduler without a plugin that bundles it): events stay on WP-Cron.
+ */
+ public function unavailable(): bool
+ {
+ return $this->active() !== CronIndex::WP_CRON && ! Schedule::engine()->driver()->available();
+ }
+
+ /**
+ * Events still kept by a driver other than the active one, by driver.
+ *
+ * An event on WP-Cron because its hook is protected, or because the driver
+ * is not there, is where it is meant to be.
+ *
+ * @return array
+ */
+ public function toMigrate(): array
+ {
+ $counts = [];
+
+ foreach (Schedule::engine()->index() as $event) {
+ if ($this->placement($event) === 'to migrate') {
+ $counts[(string) $event->driver] = ($counts[(string) $event->driver] ?? 0) + 1;
+ }
+ }
+
+ return $counts;
+ }
+
+ /**
+ * Seconds after which an event of the "laravel" driver counts as late.
+ */
+ public function lateAfter(): int
+ {
+ return max(60, (int) $this->config->get('schedule.laravel.late_after', 600));
+ }
+
+ /**
+ * Events of the "laravel" driver that are late: a job lost, or no worker.
+ */
+ public function late(): int
+ {
+ $limit = time() - $this->lateAfter();
+
+ return count(array_filter(Schedule::engine()->index(), static fn (Event $event): bool => $event->driver === LaravelQueueDriver::NAME && $event->timestamp < $limit));
+ }
+
+ /**
+ * The driver of an event, with what sets it apart from the site's: "wp-cron (protected)", "wp-cron (to migrate)".
+ */
+ public function label(Event $event): string
+ {
+ $placement = $this->placement($event);
+
+ return $event->driver.($placement === 'active' ? '' : sprintf(' (%s)', $placement));
+ }
+
+ /**
+ * Whether an event is where the site means it to be: "active" (the driver of the site), "protected" or "fallback" (on WP-Cron on purpose), or "to migrate".
+ */
+ private function placement(Event $event): string
+ {
+ if ($event->driver === $this->active()) {
+ return 'active';
+ }
+
+ if ($event->driver === CronIndex::WP_CRON && in_array($event->hook, Schedule::engine()->protectedHooks(), true)) {
+ return 'protected';
+ }
+
+ if ($event->driver === CronIndex::WP_CRON && $this->unavailable()) {
+ return 'fallback';
+ }
+
+ return 'to migrate';
+ }
+}
diff --git a/src/Schedule/Application/UseCases/RegisterSchedulerFiltersUseCase.php b/src/Schedule/Application/UseCases/RegisterSchedulerFiltersUseCase.php
deleted file mode 100644
index 91784f2a..00000000
--- a/src/Schedule/Application/UseCases/RegisterSchedulerFiltersUseCase.php
+++ /dev/null
@@ -1,66 +0,0 @@
-registerEventQueryFilters();
- $this->registerEventManagementFilters();
- }
-
- /**
- * Register filters for querying scheduled events.
- */
- private function registerEventQueryFilters(): void
- {
- $filters = [
- 'pre_get_scheduled_event' => 'preGetScheduledEvent',
- 'pre_get_ready_cron_jobs' => 'preGetReadyCronJobs',
- ];
-
- foreach ($filters as $hook => $method) {
- $this->filter->add($hook, [$this->scheduler, $method], 10, 5);
- }
- }
-
- /**
- * Register filters for managing scheduled events.
- */
- private function registerEventManagementFilters(): void
- {
- $filters = [
- 'pre_update_option_cron' => 'preUpdateOptionCron',
- 'pre_option_cron' => 'preOptionCron',
- 'pre_schedule_event' => 'preScheduleEvent',
- 'pre_reschedule_event' => 'preRescheduleEvent',
- 'pre_unschedule_event' => 'preUnscheduleEvent',
- 'pre_clear_scheduled_hook' => 'preClearScheduledHook',
- 'pre_unschedule_hook' => 'preUnscheduleHook',
- ];
-
- foreach ($filters as $hook => $method) {
- $this->filter->add($hook, [$this->scheduler, $method], 10, 5);
- }
- }
-}
diff --git a/src/Schedule/Contracts/EventInterface.php b/src/Schedule/Contracts/EventInterface.php
deleted file mode 100755
index a3ffb23a..00000000
--- a/src/Schedule/Contracts/EventInterface.php
+++ /dev/null
@@ -1,55 +0,0 @@
-hook = $event->hook;
- $this->timestamp = $event->timestamp;
- $this->args = $event->args;
- }
- }
-
- /**
- * Get the event hook name.
- */
- public function getHook(): string
- {
- return $this->hook;
- }
-
- /**
- * Get the event timestamp.
- */
- public function getTimestamp(): int
- {
- return $this->timestamp;
- }
-
- /**
- * Get the event arguments.
- */
- public function getArgs(): array
- {
- return $this->args;
- }
-
- /**
- * Create and persist a new job instance.
- *
- * @param object $event WordPress event object
- */
- public static function createJob(object $event): self
- {
- $job = new static($event);
- $dispatcher = resolve(JobDispatcher::class);
- $jobId = $dispatcher->dispatch($job);
- $job->saveToDatabase($jobId);
-
- return $job;
- }
-
- /**
- * Save the event to the database.
- *
- * @param int $jobId The queue job ID
- */
- protected function saveToDatabase($jobId)
- {
- DB::table('wp_events')->insert([
- 'hook' => $this->hook,
- 'args' => json_encode($this->args),
- 'schedule' => $this->schedule,
- 'interval' => $this->interval,
- 'is_recurring' => ! is_null($this->schedule),
- 'timestamp' => $this->timestamp,
- 'job_id' => $jobId,
- 'created_at' => now(),
- 'updated_at' => now(),
- ]);
- }
-
- /**
- * Delete the event from the database.
- */
- protected function deleteEvent()
- {
- DB::table('wp_events')->where('job_id', $this->job->getJobId())->delete();
- }
-
- /**
- * Handle the event execution.
- */
- abstract public function handle(): void;
-}
diff --git a/src/Schedule/Events/RecurringEvent.php b/src/Schedule/Events/RecurringEvent.php
deleted file mode 100755
index cc49d9ec..00000000
--- a/src/Schedule/Events/RecurringEvent.php
+++ /dev/null
@@ -1,140 +0,0 @@
-schedule = $event->schedule;
- $this->interval = $event->interval;
- }
- }
-
- /**
- * Create and persist a new recurring job instance.
- *
- * @param object $event WordPress event object\n */
- public static function createJob(object $event): self
- {
- $job = new static($event);
- $job->saveToDatabase();
-
- return $job;
- }
-
- /**
- * Save the recurring job to the database.
- *
- * @param int|null $jobId Optional job ID
- */
- protected function saveToDatabase($jobId = null): void
- {
- DB::table('wp_events')->insert([
- 'hook' => $this->hook,
- 'args' => json_encode($this->args),
- 'schedule' => $this->schedule,
- 'interval' => $this->interval,
- 'created_at' => now(),
- 'updated_at' => now(),
- ]);
- }
-
- /**
- * Schedule all recurring events in the Laravel scheduler.
- *
- * @param Schedule $schedule Laravel schedule instance
- */
- public static function scheduleAllEvents(Schedule $schedule): void
- {
- $events = DB::table('wp_events')->whereNotNull('schedule')->get();
-
- foreach ($events as $event) {
- $schedule->call(function () use ($event): void {
- do_action_ref_array($event->hook, json_decode((string) $event->args, true));
- })->cron(self::getCronExpression($event->schedule, $event->interval));
- }
- }
-
- /**
- * Convert WordPress schedule to cron expression.
- *
- * @param string $schedule WordPress schedule name
- * @param int|null $interval Custom interval in seconds
- * @return string Cron expression
- */
- public static function getCronExpression(string $schedule, ?int $interval): string
- {
- $schedules = wp_get_schedules();
-
- if (isset($schedules[$schedule])) {
- $interval = $schedules[$schedule]['interval'];
- }
-
- return match ($schedule) {
- 'minutly' => '* * * * *',
- 'hourly' => '0 * * * *',
- 'twicedaily' => '0 */12 * * *',
- 'daily' => '0 0 * * *',
- 'weekly' => '0 0 * * 0',
- default => self::calculateCustomInterval($interval),
- };
- }
-
- /**
- * Handle the recurring event execution.
- */
- public function handle(): void
- {
- do_action_ref_array($this->hook, $this->args);
- }
-
- /**
- * Calculate cron expression for custom interval.
- *
- * @param int|null $interval Interval in seconds
- * @return string Cron expression
- */
- private static function calculateCustomInterval(?int $interval): string
- {
- if ($interval === null || $interval === 0) {
- return '0 0 * * *';
- }
-
- $minutes = $interval / 60;
-
- if ($minutes < 60) {
- return sprintf('*/%s * * * *', $minutes);
- }
-
- if ($minutes == 60) {
- return '0 * * * *';
- }
-
- if ($minutes % 60 === 0) {
- $hours = $minutes / 60;
-
- return sprintf('0 */%s * * *', $hours);
- }
-
- return sprintf('*/%s * * * *', $minutes);
- }
-}
diff --git a/src/Schedule/Events/SingleEvent.php b/src/Schedule/Events/SingleEvent.php
deleted file mode 100755
index 5343c16f..00000000
--- a/src/Schedule/Events/SingleEvent.php
+++ /dev/null
@@ -1,26 +0,0 @@
-hook, $this->args);
- $this->deleteEvent();
- }
-}
diff --git a/src/Schedule/Every.php b/src/Schedule/Every.php
deleted file mode 100644
index 73f89cef..00000000
--- a/src/Schedule/Every.php
+++ /dev/null
@@ -1,44 +0,0 @@
- 'hourly',
- self::TWICE_DAILY => 'twicedaily',
- self::DAY => 'daily',
- self::WEEK => 'weekly',
- self::MONTH => 'monthly',
- self::YEAR => 'yearly',
- };
- }
-
- public function isCustom(): bool
- {
- return in_array($this, [self::MONTH, self::YEAR], true);
- }
-
- public function toInterval(): Interval
- {
- return match ($this) {
- self::HOUR => new Interval(hours: 1, display: 'Once Hourly'),
- self::TWICE_DAILY => new Interval(hours: 12, display: 'Twice Daily'),
- self::DAY => new Interval(days: 1, display: 'Once Daily'),
- self::WEEK => new Interval(weeks: 1, display: 'Once Weekly'),
- self::MONTH => new Interval(days: 30, display: 'Once Monthly'),
- self::YEAR => new Interval(days: 365, display: 'Once Yearly'),
- };
- }
-}
diff --git a/src/Schedule/Infrastructure/Checks/ScheduleDriverCheck.php b/src/Schedule/Infrastructure/Checks/ScheduleDriverCheck.php
new file mode 100644
index 00000000..20948454
--- /dev/null
+++ b/src/Schedule/Infrastructure/Checks/ScheduleDriverCheck.php
@@ -0,0 +1,83 @@
+ $problems */
+ $problems = [];
+ $fix = null;
+
+ if ($this->status->unknown()) {
+ $problems[] = sprintf('config/schedule.php asks for the driver "%s", which does not exist: every event goes to WP-Cron', $this->status->configured());
+ $fix = 'Set POLLORA_SCHEDULE_DRIVER (config/schedule.php driver) to wp-cron, action-scheduler or laravel';
+ } elseif ($this->status->unavailable()) {
+ $problems[] = sprintf('The "%s" driver is not available, so events stay on WP-Cron', $this->status->active());
+ $fix = $this->status->active() === LaravelQueueDriver::NAME
+ ? 'Point the queue at a connection a worker runs (database, redis, sqs…), never sync or null: QUEUE_CONNECTION or POLLORA_SCHEDULE_QUEUE_CONNECTION'
+ : 'Activate a plugin that bundles Action Scheduler (WooCommerce does), or set the driver back to wp-cron';
+ }
+
+ foreach ($this->status->toMigrate() as $driver => $count) {
+ $problems[] = sprintf('%d event(s) are still kept by "%s", not by the driver of the site ("%s")', $count, $driver, $this->status->active());
+ $fix ??= sprintf('php artisan pollora:schedule:migrate --from=%s --to=%s (--dry-run shows what would move)', $driver, $this->status->active());
+ }
+
+ $late = $this->status->late();
+
+ if ($late > 0) {
+ $problems[] = sprintf('%d scheduled event(s) are more than %d minutes late: no queue worker is running them', $late, intdiv($this->status->lateAfter(), 60));
+ $fix ??= 'Run a queue worker (php artisan queue:work), or send lost jobs again with php artisan pollora:schedule:recover';
+ }
+
+ foreach (array_slice(ScheduleFailures::recent(), -3) as $failure) {
+ $problems[] = sprintf('"%s" failed for good on %s: %s', $failure['hook'], gmdate('Y-m-d H:i', $failure['time']).' UTC', $failure['message']);
+ $fix ??= 'See php artisan queue:failed; the event is out of the schedule if it was a single one';
+ }
+
+ if ($problems === []) {
+ return CheckResult::ok(sprintf('Scheduled events are kept by "%s".', $this->status->active()));
+ }
+
+ return CheckResult::warning(sprintf('%d problem(s) with scheduled events.', count($problems)), $problems, (string) $fix);
+ }
+}
diff --git a/src/Schedule/Infrastructure/Drivers/LaravelQueueDriver.php b/src/Schedule/Infrastructure/Drivers/LaravelQueueDriver.php
new file mode 100644
index 00000000..db80fc6b
--- /dev/null
+++ b/src/Schedule/Infrastructure/Drivers/LaravelQueueDriver.php
@@ -0,0 +1,180 @@
+ 900];
+
+ /**
+ * The bus is resolved when a job is queued: the driver is built while the application is still registering its providers.
+ */
+ public function __construct(
+ private Container $container,
+ private Repository $config,
+ ) {}
+
+ public function available(): bool
+ {
+ $driver = $this->config->get(sprintf('queue.connections.%s.driver', $this->connection()));
+
+ return is_string($driver) && ! in_array($driver, ['sync', 'null'], true);
+ }
+
+ public function placeIdentified(Event $event): bool
+ {
+ $this->send(RunScheduledEvent::for($this->withInterval($event)));
+
+ return true;
+ }
+
+ /**
+ * A recurring event runs again only when its job knows the interval: the fluent API and #[Schedule] give the name of the recurrence, WordPress's own functions give both.
+ */
+ private function withInterval(Event $event): Event
+ {
+ if ($event->recurrence === null || $event->interval !== null) {
+ return $event;
+ }
+
+ $interval = (int) ((function_exists('wp_get_schedules') ? wp_get_schedules() : [])[$event->recurrence]['interval'] ?? 0);
+
+ return $interval > 0
+ ? new Event($event->hook, $event->timestamp, $event->recurrence, $event->args, $interval, $event->driver, $event->id)
+ : $event;
+ }
+
+ public function once(string $hook, int $timestamp, array $args = []): bool
+ {
+ return $this->place(new Event($hook, $timestamp, null, $args));
+ }
+
+ public function recurring(string $hook, int $timestamp, string $recurrence, array $args = []): bool
+ {
+ return $this->place($this->withInterval(new Event($hook, $timestamp, $recurrence, $args)));
+ }
+
+ /**
+ * What the engine removes from the index is cancelled: a job that finds no entry stops.
+ */
+ public function cancel(string $hook, array $args = []): int
+ {
+ return count(array_filter($this->events(), static fn (Event $event): bool => $event->hook === $hook && $event->args === $args));
+ }
+
+ public function clear(string $hook): int
+ {
+ return count(array_filter($this->events(), static fn (Event $event): bool => $event->hook === $hook));
+ }
+
+ public function next(string $hook, array $args = []): ?int
+ {
+ foreach ($this->events() as $event) {
+ if ($event->hook === $hook && $event->args === $args) {
+ return $event->timestamp;
+ }
+ }
+
+ return null;
+ }
+
+ public function events(): array
+ {
+ return CronIndex::events(self::NAME);
+ }
+
+ /**
+ * Queue the job of an event, delayed until it is due, or by the longest delay the connection accepts: the job then waits again.
+ */
+ public function send(RunScheduledEvent $job): void
+ {
+ $delay = max(0, $job->timestamp - time());
+ $longest = $this->longestDelay();
+
+ if ($longest !== null && $delay > $longest) {
+ $delay = $longest;
+ }
+
+ $job->onConnection($this->connection())->onQueue($this->queue());
+
+ if ($delay > 0) {
+ $job->delay($delay);
+ }
+
+ $this->container->make(Dispatcher::class)->dispatch($job);
+ }
+
+ /**
+ * Seconds after which an event that has not run is late, and the recovery sends it again.
+ */
+ public function lateAfter(): int
+ {
+ return max(60, (int) $this->config->get('schedule.laravel.late_after', 600));
+ }
+
+ /**
+ * Where the identifier of a placement is not known to the engine: record the event and queue it.
+ */
+ private function place(Event $event): bool
+ {
+ $event = $event->withId(bin2hex(random_bytes(8)));
+
+ CronIndex::record($event, self::NAME);
+ $this->placeIdentified($event);
+
+ return true;
+ }
+
+ private function connection(): string
+ {
+ $connection = $this->config->get('schedule.laravel.connection');
+
+ return is_string($connection) && $connection !== '' ? $connection : (string) $this->config->get('queue.default', 'sync');
+ }
+
+ private function queue(): ?string
+ {
+ $queue = $this->config->get('schedule.laravel.queue');
+
+ return is_string($queue) && $queue !== '' ? $queue : null;
+ }
+
+ private function longestDelay(): ?int
+ {
+ $configured = $this->config->get('schedule.laravel.max_delay');
+
+ if (is_numeric($configured) && (int) $configured > 0) {
+ return (int) $configured;
+ }
+
+ $driver = $this->config->get(sprintf('queue.connections.%s.driver', $this->connection()));
+
+ return is_string($driver) ? (self::MAX_DELAYS[$driver] ?? null) : null;
+ }
+}
diff --git a/src/Schedule/Infrastructure/Jobs/RunScheduledEvent.php b/src/Schedule/Infrastructure/Jobs/RunScheduledEvent.php
new file mode 100644
index 00000000..7eaa1664
--- /dev/null
+++ b/src/Schedule/Infrastructure/Jobs/RunScheduledEvent.php
@@ -0,0 +1,193 @@
+ $args
+ */
+ public function __construct(
+ public readonly string $hook,
+ public readonly array $args,
+ public readonly int $timestamp,
+ public readonly string $id,
+ public readonly ?string $recurrence = null,
+ public readonly ?int $interval = null,
+ public readonly int $site = 1,
+ ) {}
+
+ public static function for(Event $event): self
+ {
+ return new self(
+ $event->hook,
+ $event->args,
+ $event->timestamp,
+ (string) $event->id,
+ $event->recurrence,
+ $event->interval,
+ function_exists('get_current_blog_id') ? get_current_blog_id() : 1,
+ );
+ }
+
+ public function handle(LaravelQueueDriver $driver): void
+ {
+ $this->inSite(function () use ($driver): void {
+ if (! $this->entryHolds()) {
+ return;
+ }
+
+ // The delay was cut short for a connection with a ceiling: wait again
+ if ($this->timestamp - time() > 1) {
+ $driver->send($this->again());
+
+ return;
+ }
+
+ // One run at a time: a copy sent again while this one is still working finds the lock taken
+ $lock = Cache::lock('pollora_schedule_run_'.$this->id, 3600);
+
+ if (! $lock->get()) {
+ return;
+ }
+
+ try {
+ do_action_ref_array($this->hook, $this->args);
+ $this->conclude($driver);
+ } finally {
+ $lock->release();
+ }
+ });
+ }
+
+ /**
+ * Retries are over: a recurring event goes on with its next occurrence, a single one leaves the index, and the doctor is told.
+ */
+ public function failed(Throwable $exception): void
+ {
+ $this->inSite(function () use ($exception): void {
+ if (! $this->entryHolds()) {
+ return;
+ }
+
+ ScheduleFailures::record($this->hook, $exception->getMessage());
+ $this->conclude(resolve(LaravelQueueDriver::class));
+ });
+ }
+
+ /**
+ * Name shown by Horizon and in the failed jobs table.
+ */
+ public function displayName(): string
+ {
+ return 'pollora/schedule '.$this->hook;
+ }
+
+ /**
+ * Tags for Horizon.
+ *
+ * @return list
+ */
+ public function tags(): array
+ {
+ return ['pollora/schedule', $this->hook];
+ }
+
+ /**
+ * Whether the index still has this event, at this time, under this identifier.
+ */
+ private function entryHolds(): bool
+ {
+ // A worker lives across jobs: what WordPress cached in memory, the cron option included, is from before
+ if (function_exists('wp_cache_flush_runtime')) {
+ wp_cache_flush_runtime();
+ }
+
+ foreach (CronIndex::events(LaravelQueueDriver::NAME) as $event) {
+ if ($event->hook === $this->hook && $event->args === $this->args) {
+ return $event->id === $this->id && $event->timestamp === $this->timestamp;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * What follows a run: the next occurrence of a recurring event (never in the past), or the end of a single one.
+ */
+ private function conclude(LaravelQueueDriver $driver): void
+ {
+ if ($this->recurrence === null || $this->interval === null || $this->interval <= 0) {
+ CronIndex::forget($this->hook, $this->args, LaravelQueueDriver::NAME);
+
+ return;
+ }
+
+ $next = $this->timestamp + $this->interval;
+
+ if ($next <= time()) {
+ $next += (int) ceil((time() - $next + 1) / $this->interval) * $this->interval;
+ }
+
+ $event = new Event($this->hook, $next, $this->recurrence, $this->args, $this->interval, LaravelQueueDriver::NAME, bin2hex(random_bytes(8)));
+
+ CronIndex::record($event, LaravelQueueDriver::NAME);
+ $driver->send(self::for($event));
+ }
+
+ /**
+ * The same promise as a new job: the one a worker holds carries its queue message.
+ */
+ private function again(): self
+ {
+ return new self($this->hook, $this->args, $this->timestamp, $this->id, $this->recurrence, $this->interval, $this->site);
+ }
+
+ /**
+ * Run in the site that scheduled the event: on a multisite network the queue and its worker are shared.
+ */
+ private function inSite(callable $run): void
+ {
+ $switch = function_exists('is_multisite') && is_multisite() && $this->site !== get_current_blog_id();
+
+ if ($switch) {
+ switch_to_blog($this->site);
+ }
+
+ try {
+ $run();
+ } finally {
+ if ($switch) {
+ restore_current_blog();
+ }
+ }
+ }
+}
diff --git a/src/Schedule/Infrastructure/Services/LateEventRecovery.php b/src/Schedule/Infrastructure/Services/LateEventRecovery.php
new file mode 100644
index 00000000..7e06b311
--- /dev/null
+++ b/src/Schedule/Infrastructure/Services/LateEventRecovery.php
@@ -0,0 +1,92 @@
+get()) {
+ return 0;
+ }
+
+ try {
+ $sent = 0;
+
+ foreach ($this->late() as $event) {
+ if ($event->id === null) {
+ $event = $event->withId(bin2hex(random_bytes(8)));
+ CronIndex::record($event, LaravelQueueDriver::NAME);
+ }
+
+ // Sent again, then left alone for as long as it takes to count as late again
+ if (! $force && ! Cache::add('pollora_schedule_resent_'.$event->id, 1, $this->driver->lateAfter())) {
+ continue;
+ }
+
+ $this->driver->send(RunScheduledEvent::for($event));
+ $sent++;
+ }
+
+ return $sent;
+ } finally {
+ $lock->release();
+ }
+ }
+
+ /**
+ * The events of the index more than late_after seconds past their time; one without an identifier gets one when it is sent again.
+ *
+ * @return list
+ */
+ public function late(): array
+ {
+ $late = [];
+
+ foreach (CronIndex::events(LaravelQueueDriver::NAME) as $event) {
+ if ($event->timestamp >= time() - $this->driver->lateAfter()) {
+ break;
+ }
+
+ $late[] = $event;
+ }
+
+ return $late;
+ }
+}
diff --git a/src/Schedule/Infrastructure/Services/MigrationNotice.php b/src/Schedule/Infrastructure/Services/MigrationNotice.php
new file mode 100644
index 00000000..efdc2c50
--- /dev/null
+++ b/src/Schedule/Infrastructure/Services/MigrationNotice.php
@@ -0,0 +1,55 @@
+render(...));
+ add_action('network_admin_notices', $this->render(...));
+ }
+ }
+
+ public function render(): void
+ {
+ if (! current_user_can('manage_options')) {
+ return;
+ }
+
+ $pending = $this->status->toMigrate();
+
+ if ($pending === []) {
+ return;
+ }
+
+ $driver = array_key_first($pending);
+
+ printf(
+ '',
+ esc_html__('Scheduled events are still kept by another driver.', 'pollora'),
+ esc_html(sprintf(
+ /* translators: 1: number of events, 2: driver name, 3: driver of the site */
+ __('%1$d event(s) wait in "%2$s", while the site uses "%3$s". Move them with', 'pollora'),
+ array_sum($pending),
+ $driver,
+ $this->status->active(),
+ )),
+ esc_html(sprintf('php artisan pollora:schedule:migrate --from=%s --to=%s', $driver, $this->status->active())),
+ );
+ }
+}
diff --git a/src/Schedule/Infrastructure/Services/ScheduleDiscovery.php b/src/Schedule/Infrastructure/Services/ScheduleDiscovery.php
deleted file mode 100644
index b01db376..00000000
--- a/src/Schedule/Infrastructure/Services/ScheduleDiscovery.php
+++ /dev/null
@@ -1,351 +0,0 @@
-
- */
- private const array DEFAULT_SCHEDULES = [
- 'hourly',
- 'twicedaily',
- 'daily',
- 'weekly',
- ];
-
- /**
- * {@inheritDoc}
- *
- * Discovers methods with Schedule attributes and collects them for registration.
- * Only processes public methods that have the Schedule attribute.
- */
- public function discover(DiscoveryLocationInterface $location, DiscoveredStructure $structure, ?ReflectionCacheInterface $reflectionCache = null): void
- {
- // Only process classes
- if (! $structure instanceof DiscoveredClass) {
- return;
- }
-
- // Skip abstract classes
- if ($structure->isAbstract) {
- return;
- }
-
- try {
- $className = $structure->namespace.'\\'.$structure->name;
- $methods = $reflectionCache->getPublicMethods($className);
-
- foreach ($methods as $method) {
- $scheduleAttributes = $method->getAttributes(Schedule::class);
-
- if (empty($scheduleAttributes)) {
- continue;
- }
-
- foreach ($scheduleAttributes as $scheduleAttribute) {
- // Collect the method for registration
- $this->getItems()->add($location, [
- 'class' => $className,
- 'method' => $method->getName(),
- 'attribute' => $scheduleAttribute,
- 'reflection_method' => $method,
- ]);
- }
- }
- } catch (\Throwable) {
- // Skip classes that can't be reflected
- // This might happen for classes with missing dependencies
- return;
- }
- }
-
- /**
- * {@inheritDoc}
- *
- * Applies discovered Schedule methods by registering them as WordPress cron events.
- * Each discovered method is processed to extract schedule information, validate
- * recurrence definitions, register custom schedules if needed, and finally
- * register the scheduled task with WordPress cron system.
- */
- public function apply(): void
- {
- foreach ($this->getItems() as $discoveredItem) {
- [
- 'class' => $className,
- 'method' => $methodName,
- 'attribute' => $scheduleAttribute,
- 'reflection_method' => $reflectionMethod
- ] = $discoveredItem;
-
- try {
- // Get the Schedule attribute instance
- /** @var Schedule $schedule */
- $schedule = $scheduleAttribute->newInstance();
-
- // Determine hook name (custom or generated)
- $hookName = $schedule->hook ?? $this->generateHookName($className, $methodName);
-
- // Process the recurrence definition and get the schedule identifier
- $scheduleIdentifier = $this->processRecurrence($schedule->recurrence, $hookName);
-
- // Register the action handler that will execute the scheduled method
- add_action($hookName, function () use ($className, $methodName, $schedule): void {
- // Use instance pool if available, otherwise fallback to container
- $instance = $this->getInstanceFromPool($className);
-
- // Call method with arguments if provided
- if ($schedule->args !== []) {
- $instance->{$methodName}($schedule->args);
- } else {
- $instance->{$methodName}();
- }
- });
-
- // Schedule the cron event if not already scheduled
- if (! wp_next_scheduled($hookName, $schedule->args)) {
- $this->scheduleWordPressCron($hookName, $scheduleIdentifier, $schedule->args);
- }
- } catch (\Throwable $e) {
- // Log the error but continue with other scheduled tasks
- $this->logger?->error(sprintf('Failed to register Schedule from method %s::%s', $className, $methodName), ['exception' => $e]);
- }
- }
- }
-
- /**
- * Generate a hook name for a class method.
- *
- * Creates a human-readable hook name based on the class and method names
- * using snake_case formatting, following WordPress naming conventions.
- *
- * @param string $className The fully qualified class name
- * @param string $methodName The method name
- * @return string The generated hook name in snake_case format
- */
- private function generateHookName(string $className, string $methodName): string
- {
- // Extract just the class name without namespace
- $shortClassName = class_basename($className);
-
- // Convert to snake_case following WordPress conventions
- return Str::snake($shortClassName).'_'.Str::snake($methodName);
- }
-
- /**
- * Process and validate a recurrence definition.
- *
- * Converts various recurrence types (string, array, Every, Interval) into
- * a standardized schedule identifier that can be used by WordPress cron.
- * Also handles registration of custom schedules when needed.
- *
- * @param string|array|Every|Interval $recurrence The recurrence definition to process
- * @param string $hookName The hook name for custom schedule registration
- * @return string The processed schedule identifier
- *
- * @throws InvalidArgumentException If the recurrence type is unsupported or invalid
- */
- private function processRecurrence(string|array|Every|Interval $recurrence, string $hookName): string
- {
- return match (true) {
- // Handle string-based WordPress schedules
- is_string($recurrence) => $this->validateAndReturnStringRecurrence($recurrence),
-
- // Handle array-based custom schedules
- is_array($recurrence) => $this->processArrayRecurrence($recurrence, $hookName),
-
- // Handle Every enum values
- $recurrence instanceof Every => $this->processEveryRecurrence($recurrence),
-
- // Handle Interval instances (only remaining type)
- default => $this->processIntervalRecurrence($recurrence, $hookName),
- };
- }
-
- /**
- * Validate and return a string-based WordPress schedule.
- *
- * Ensures the provided string matches one of WordPress's built-in schedule intervals.
- *
- * @param string $recurrence The WordPress schedule name to validate
- * @return string The validated schedule name
- *
- * @throws InvalidArgumentException If the schedule name is not recognized
- */
- private function validateAndReturnStringRecurrence(string $recurrence): string
- {
- if (! in_array($recurrence, self::DEFAULT_SCHEDULES, true)) {
- throw new InvalidArgumentException(
- sprintf(
- 'Invalid WordPress schedule "%s". Allowed schedules: %s',
- $recurrence,
- implode(', ', self::DEFAULT_SCHEDULES)
- )
- );
- }
-
- return $recurrence;
- }
-
- /**
- * Process an array-based custom schedule.
- *
- * Validates that the array contains required 'interval' and 'display' keys,
- * registers the custom schedule with WordPress, and returns the schedule identifier.
- *
- * @param array $recurrence The custom schedule definition array
- * @param string $hookName The hook name to use as schedule identifier
- * @return string The custom schedule identifier
- *
- * @throws InvalidArgumentException If the array structure is invalid
- */
- private function processArrayRecurrence(array $recurrence, string $hookName): string
- {
- if (! isset($recurrence['interval']) || ! is_numeric($recurrence['interval'])) {
- throw new InvalidArgumentException(
- 'Custom schedule array must contain a numeric "interval" key (seconds)'
- );
- }
-
- if (! isset($recurrence['display']) || ! is_string($recurrence['display'])) {
- throw new InvalidArgumentException(
- 'Custom schedule array must contain a string "display" key (human-readable name)'
- );
- }
-
- // Register the custom schedule with WordPress
- $scheduleKey = 'custom_'.md5($hookName);
- $this->registerCustomSchedule($scheduleKey, $recurrence);
-
- return $scheduleKey;
- }
-
- /**
- * Process Every enum recurrence values.
- *
- * Converts Every enum values to their corresponding WordPress schedule names
- * or registers custom schedules for non-standard intervals like MONTH/YEAR.
- *
- * @param Every $recurrence The Every enum value to process
- * @return string The processed schedule identifier
- */
- private function processEveryRecurrence(Every $recurrence): string
- {
- // For standard WordPress schedules, return the direct mapping
- if (! $recurrence->isCustom()) {
- return $recurrence->toScheduleKey();
- }
-
- // For custom schedules (MONTH, YEAR), register and return identifier
- $scheduleKey = 'every_'.strtolower($recurrence->name);
- $interval = $recurrence->toInterval();
-
- $this->registerCustomSchedule($scheduleKey, [
- 'interval' => $interval->totalSeconds(),
- 'display' => $interval->display,
- ]);
-
- return $scheduleKey;
- }
-
- /**
- * Process Interval instance recurrence values.
- *
- * Registers a custom schedule for the Interval and returns its identifier.
- *
- * @param Interval $recurrence The Interval instance to process
- * @param string $hookName The hook name for custom schedule registration
- * @return string The custom schedule identifier
- */
- private function processIntervalRecurrence(Interval $recurrence, string $hookName): string
- {
- // Generate unique identifier based on interval
- $scheduleKey = 'interval_'.md5($hookName.'_'.$recurrence->totalSeconds());
-
- $this->registerCustomSchedule($scheduleKey, [
- 'interval' => $recurrence->totalSeconds(),
- 'display' => $recurrence->display,
- ]);
-
- return $scheduleKey;
- }
-
- /**
- * Register a custom schedule with WordPress.
- *
- * Adds a new schedule interval to WordPress's cron system using the cron_schedules filter.
- * This allows custom intervals beyond WordPress's built-in options.
- *
- * @param string $scheduleKey The unique key for the custom schedule
- * @param array $schedule The schedule definition with 'interval' and 'display' keys
- */
- private function registerCustomSchedule(string $scheduleKey, array $schedule): void
- {
- add_filter('cron_schedules', function (array $schedules) use ($scheduleKey, $schedule): array {
- $schedules[$scheduleKey] = [
- 'interval' => (int) $schedule['interval'],
- 'display' => (string) $schedule['display'],
- ];
-
- return $schedules;
- });
- }
-
- /**
- * Schedule a WordPress cron event
- *
- * @param string $hookName The hook name to schedule
- * @param string $interval The interval (e.g., 'hourly', 'daily')
- * @param array $args Optional arguments to pass to the hook
- */
- private function scheduleWordPressCron(string $hookName, string $interval, array $args = []): void
- {
- if (wp_schedule_event(time(), $interval, $hookName, $args) === false) {
- $this->logger?->error('Failed to schedule WordPress cron event for hook: '.$hookName);
- }
- }
-
- /**
- * {@inheritDoc}
- */
- public function getIdentifier(): string
- {
- return 'schedules';
- }
-}
diff --git a/src/Schedule/Infrastructure/Services/ScheduleFailures.php b/src/Schedule/Infrastructure/Services/ScheduleFailures.php
new file mode 100644
index 00000000..e5ca27c4
--- /dev/null
+++ b/src/Schedule/Infrastructure/Services/ScheduleFailures.php
@@ -0,0 +1,46 @@
+ $hook, 'time' => time(), 'message' => mb_substr($message, 0, 300)];
+
+ update_option(self::OPTION, array_slice($failures, -self::KEEP), false);
+ }
+
+ /**
+ * Failures of the last week, oldest first.
+ *
+ * @return list
+ */
+ public static function recent(): array
+ {
+ $stored = get_option(self::OPTION, []);
+ $recent = [];
+
+ foreach (is_array($stored) ? $stored : [] as $failure) {
+ if (is_array($failure) && is_string($failure['hook'] ?? null) && is_int($failure['time'] ?? null) && $failure['time'] > time() - self::WEEK) {
+ $recent[] = ['hook' => $failure['hook'], 'time' => $failure['time'], 'message' => (string) ($failure['message'] ?? '')];
+ }
+ }
+
+ return $recent;
+ }
+}
diff --git a/src/Schedule/Interval.php b/src/Schedule/Interval.php
deleted file mode 100644
index 448c2c91..00000000
--- a/src/Schedule/Interval.php
+++ /dev/null
@@ -1,34 +0,0 @@
- $this->totalSeconds(),
- 'display' => $this->display,
- ];
- }
-
- public function totalSeconds(): int
- {
- return $this->seconds
- + $this->minutes * 60
- + $this->hours * 3600
- + $this->days * 86400
- + $this->weeks * 604800;
- }
-}
diff --git a/src/Schedule/Jobs/JobDispatcher.php b/src/Schedule/Jobs/JobDispatcher.php
deleted file mode 100755
index f3fc7fd1..00000000
--- a/src/Schedule/Jobs/JobDispatcher.php
+++ /dev/null
@@ -1,35 +0,0 @@
-dispatcher->dispatch($job);
- }
-}
diff --git a/src/Schedule/Scheduler.php b/src/Schedule/Scheduler.php
deleted file mode 100755
index c4c59006..00000000
--- a/src/Schedule/Scheduler.php
+++ /dev/null
@@ -1,370 +0,0 @@
-processCronDifferences($old_value, $value);
-
- return $old_value;
- }
-
- /**
- * Retrieve the cron option.
- *
- * @param mixed $value Current option value
- * @return array The cron jobs array
- */
- public function preOptionCron($value): array
- {
- if ($value !== false) {
- return $value;
- }
-
- return $this->generateCronArray();
- }
-
- /**
- * Schedule a new event.
- *
- * @param mixed $pre Pre-filtered value
- * @param object $event Event to schedule
- * @param bool $wp_error Whether to return WP_Error on failure
- * @return AbstractEvent|WP_Error|null The scheduled job or error
- */
- public function preScheduleEvent($pre, object $event, bool $wp_error): AbstractEvent|WP_Error|null
- {
- if ($pre !== null) {
- return $pre;
- }
-
- try {
- $job = $event->schedule ? new RecurringEvent($event) : new SingleEvent($event);
-
- return $job->createJob($event);
- } catch (\Throwable $throwable) {
- return $wp_error ? new WP_Error('schedule_error', $throwable->getMessage()) : null;
- }
- }
-
- /**
- * Reschedule an event.
- *
- * @param mixed $pre Pre-filtered value
- * @param object $event Event to reschedule
- * @param bool $wp_error Whether to return WP_Error on failure
- * @return AbstractEvent|WP_Error|null The rescheduled job or error
- */
- public function preRescheduleEvent($pre, object $event, bool $wp_error): AbstractEvent|WP_Error|null
- {
- if ($pre !== null) {
- return $pre;
- }
-
- try {
- $job = new RecurringEvent($event);
-
- return $job->createJob($event);
- } catch (\Throwable $throwable) {
- return $wp_error ? new WP_Error('reschedule_error', $throwable->getMessage()) : null;
- }
- }
-
- /**
- * Unschedule an event.
- *
- * @param mixed $pre Pre-filtered value
- * @param int $timestamp Event timestamp
- * @param string $hook Event hook
- * @param array $args Event arguments
- * @param bool $wp_error Whether to return WP_Error on failure
- * @return bool|WP_Error Whether the event was unscheduled
- */
- public function preUnscheduleEvent($pre, int $timestamp, string $hook, array $args, bool $wp_error): bool|WP_Error
- {
- if ($pre !== null) {
- return $pre;
- }
-
- try {
- $deleted = DB::table('wp_events')
- ->where('hook', $hook)
- ->where('timestamp', $timestamp)
- ->where('args', json_encode($args))
- ->delete();
-
- if ($deleted) {
- $this->deleteAssociatedJob($hook, $timestamp, $args);
- }
-
- return $deleted > 0;
- } catch (\Throwable $throwable) {
- return $wp_error ? new WP_Error('unschedule_error', $throwable->getMessage()) : false;
- }
- }
-
- /**
- * Clear all scheduled hooks.
- *
- * @param mixed $pre Pre-filtered value
- * @param string $hook Hook to clear
- * @param array|null $args Arguments to match
- * @param bool $wp_error Whether to return WP_Error on failure
- * @return int|WP_Error The number of events cleared
- */
- public function preClearScheduledHook($pre, string $hook, ?array $args, bool $wp_error): int|WP_Error
- {
- if ($pre !== null) {
- return $pre;
- }
-
- try {
- $query = DB::table('wp_events')->where('hook', $hook);
-
- if ($args !== null) {
- $query->where('args', json_encode($args));
- }
-
- $events = $query->get();
- $count = $events->count();
-
- foreach ($events as $event) {
- $this->deleteAssociatedJob($event->hook, $event->timestamp, json_decode((string) $event->args, true));
- }
-
- $query->delete();
-
- return $count;
- } catch (\Throwable $throwable) {
- return $wp_error ? new WP_Error('clear_hook_error', $throwable->getMessage()) : 0;
- }
- }
-
- protected function deleteAssociatedJob(string $hook, int $timestamp, array $args)
- {
- $jobId = DB::table('wp_events')
- ->where('hook', $hook)
- ->where('timestamp', $timestamp)
- ->where('args', json_encode($args))
- ->value('job_id');
-
- if ($jobId) {
- DB::table('jobs')->where('id', $jobId)->delete();
- }
- }
-
- /**
- * Unschedule all events attached to a specific hook.
- *
- * @param mixed $pre Pre-filtered value
- * @param string $hook Hook to unschedule
- * @param bool $wp_error Whether to return WP_Error on failure
- * @return int|WP_Error The number of events unscheduled
- */
- public function preUnscheduleHook($pre, string $hook, bool $wp_error): int|WP_Error
- {
- return $this->preClearScheduledHook($pre, $hook, null, $wp_error);
- }
-
- /**
- * Retrieve a scheduled event.
- *
- * @param mixed $pre Pre-filtered value
- * @param string $hook Event hook
- * @param array $args Event arguments
- * @param int|null $timestamp Unix timestamp (UTC) of the event. Null to retrieve next scheduled event.
- * @return object|false The event object or false if not found
- */
- public function preGetScheduledEvent($pre, string $hook, array $args, ?int $timestamp): object|false
- {
- if ($pre !== null) {
- return $pre;
- }
-
- $query = DB::table('wp_events')
- ->where('hook', $hook);
-
- if ($args !== []) {
- $query->where('args', json_encode($args));
- }
-
- if ($timestamp !== null) {
- $query->where('timestamp', $timestamp);
- }
-
- $event = $query->first();
-
- if (! $event) {
- return false;
- }
-
- return $this->convertDbEventToWpEvent($event);
- }
-
- protected function convertDbEventToWpEvent(object $event): object
- {
- return (object) [
- 'hook' => $event->hook,
- 'timestamp' => $event->schedule ? $this->getNextRunTime($event->schedule, $event->interval) : $event->timestamp,
- 'schedule' => $event->schedule,
- 'args' => json_decode($event->args, true),
- 'interval' => $event->interval,
- ];
- }
-
- /**
- * Generate a unique job ID.
- *
- * @param int|null $timestamp Event timestamp
- * @param string $hook Event hook
- * @param array $args Event arguments
- * @return string The generated job ID
- */
- protected function generateJobId(?int $timestamp, string $hook, array $args): string
- {
- return md5(serialize([$timestamp, $hook, $args]));
- }
-
- /**
- * Convert a job to a WordPress event object.
- *
- * @param object $job The job to convert
- * @return object The converted event object
- */
- protected function convertJobToEvent(object $job): object
- {
- $event = new \stdClass;
- $event->hook = $job->wp_hook;
- $event->timestamp = $job->available_at;
- $event->args = json_decode($job->wp_args, true);
- $event->schedule = $job->wp_schedule ?? false;
- $event->interval = $job->wp_interval ?? null;
-
- return $event;
- }
-
- /**
- * Process the differences between old and new cron arrays.
- *
- * @param array $oldCrons The old cron array
- * @param array $newCrons The new cron array
- */
- protected function processCronDifferences(array $oldCrons, array $newCrons): void
- {
- // Implementation to handle differences (add, update, delete jobs)
- }
-
- /**
- * Generate a cron array from the current queue jobs.
- *
- * @return array The generated cron array
- */
- protected function generateCronArray(): array
- {
- $jobs = $this->getAllJobs();
-
- return $this->convertJobsToWordPressCronArray($jobs);
- }
-
- /**
- * Get all jobs from the queue.
- */
- protected function getAllJobs(): array
- {
- return DB::table('wp_events')
- ->orderBy('timestamp', 'asc')
- ->get()
- ->map(fn (object $job): object => $this->convertDbEventToWpEvent($job))
- ->all();
- }
-
- /**
- * Retrieve cron jobs ready to be run.
- *
- * @param mixed $pre Pre-filtered value
- * @return array The array of ready cron jobs
- */
- public function preGetReadyCronJobs($pre): array
- {
- if ($pre !== null) {
- return $pre;
- }
-
- return [];
- }
-
- /**
- * Convert an array of jobs to a WordPress cron array format.
- *
- * @param array $jobs The jobs to convert
- * @return array The converted cron array
- */
- protected function convertJobsToWordPressCronArray(array $jobs): array
- {
- $crons = [];
-
- foreach ($jobs as $job) {
- if (! $job->hook) {
- continue;
- }
-
- $timestamp = $job->timestamp;
- $hook = $job->hook;
- $key = md5(serialize($job->args));
-
- $crons[$timestamp][$hook][$key] = [
- 'schedule' => $job->schedule,
- 'args' => $job->args,
- ];
-
- if ($job->interval) {
- $crons[$timestamp][$hook][$key]['interval'] = $job->interval;
- }
- }
-
- ksort($crons, SORT_NUMERIC);
- $crons['version'] = 2;
-
- return $crons;
- }
-
- /**
- * Get the next run time for a scheduled event.
- *
- * @param string $schedule WordPress schedule name
- * @param int|null $interval Custom interval in seconds
- * @return int Next run timestamp
- */
- protected function getNextRunTime(string $schedule, ?int $interval): int
- {
- $cron = RecurringEvent::getCronExpression($schedule, $interval);
- $cron = new CronExpression($cron);
-
- return $cron->getNextRunDate()->getTimestamp();
- }
-}
diff --git a/src/Schedule/SchedulerDiscoveryServiceProvider.php b/src/Schedule/SchedulerDiscoveryServiceProvider.php
index 19e1c7e0..529aae99 100755
--- a/src/Schedule/SchedulerDiscoveryServiceProvider.php
+++ b/src/Schedule/SchedulerDiscoveryServiceProvider.php
@@ -22,5 +22,7 @@ class SchedulerDiscoveryServiceProvider extends ServiceProvider
public function register(): void
{
$this->app->singleton(ScheduleDiscovery::class, fn (): ScheduleDiscovery => new ScheduleDiscovery);
+
+ Schedule::useContainer($this->app);
}
}
diff --git a/src/Schedule/SchedulerServiceProvider.php b/src/Schedule/SchedulerServiceProvider.php
index ca4f178a..87998f59 100755
--- a/src/Schedule/SchedulerServiceProvider.php
+++ b/src/Schedule/SchedulerServiceProvider.php
@@ -4,22 +4,24 @@
namespace Pollora\Schedule;
-use Illuminate\Console\Scheduling\Schedule;
+use Illuminate\Contracts\Config\Repository;
use Illuminate\Contracts\Foundation\Application;
-use Illuminate\Database\QueryException;
use Illuminate\Support\ServiceProvider;
-use Orchestra\Testbench\TestCase;
-use Pollora\Hook\Domain\Contract\Filter;
-use Pollora\Schedule\Application\UseCases\RegisterSchedulerFiltersUseCase;
-use Pollora\Schedule\Contracts\SchedulerInterface;
-use Pollora\Schedule\Events\RecurringEvent;
+use Pollora\Doctor\Infrastructure\Providers\DoctorServiceProvider;
+use Pollora\Schedule\Application\Services\ScheduleStatus;
+use Pollora\Schedule\Infrastructure\Checks\ScheduleDriverCheck;
+use Pollora\Schedule\Infrastructure\Drivers\LaravelQueueDriver;
+use Pollora\Schedule\Infrastructure\Services\LateEventRecovery;
+use Pollora\Schedule\Infrastructure\Services\MigrationNotice;
use Pollora\Schedule\Infrastructure\Services\ScheduleDiscovery;
+use Pollora\Schedule\Schedule as ScheduleApi;
+use Pollora\Schedule\UI\Console\ScheduleListCommand;
+use Pollora\Schedule\UI\Console\ScheduleMigrateCommand;
+use Pollora\Schedule\UI\Console\ScheduleRecoverCommand;
/**
- * Service provider for WordPress cron scheduler functionality.
- *
- * Registers and bootstraps the scheduler services, including filters
- * and recurring event scheduling.
+ * Service provider for scheduled events: the driver of the site, the commands
+ * and the checks around it.
*/
class SchedulerServiceProvider extends ServiceProvider
{
@@ -29,7 +31,18 @@ class SchedulerServiceProvider extends ServiceProvider
public function register(): void
{
$this->registerDomainContracts();
- $this->registerUseCases();
+
+ $this->mergeConfigFrom(__DIR__.'/../../config/schedule.php', 'schedule');
+ $this->app->singleton(ScheduleStatus::class, fn (Application $app): ScheduleStatus => new ScheduleStatus($app->make(Repository::class)));
+
+ // Before anything creates the engine: POLLORA_SCHEDULE_DRIVER may name it
+ ScheduleApi::extend(LaravelQueueDriver::NAME, fn (): LaravelQueueDriver => $this->app->make(LaravelQueueDriver::class));
+ ScheduleApi::useContainer($this->app);
+ $this->chooseDriver($this->app->make(Repository::class));
+
+ if ($this->app->runningInConsole()) {
+ $this->commands([ScheduleListCommand::class, ScheduleMigrateCommand::class, ScheduleRecoverCommand::class]);
+ }
}
/**
@@ -37,58 +50,61 @@ public function register(): void
*/
public function boot(): void
{
- if (config('wordpress.use_laravel_scheduler', false)) {
- $this->app->make(RegisterSchedulerFiltersUseCase::class)->execute();
- }
+ $this->publishes([
+ __DIR__.'/../../config/schedule.php' => config_path('schedule.php'),
+ ], 'pollora-schedule');
+
+ // A check of pollora:doctor and Site Health; tagged on boot, so it comes after the framework's own
+ $this->app->tag([ScheduleDriverCheck::class], DoctorServiceProvider::CHECKS_TAG);
+ $this->app->make(MigrationNotice::class)->register();
$this->app->booted(function (): void {
- $this->scheduleRecurringEvents();
+ // The driver was chosen when the application started; WordPress can take its hooks by now
+ ScheduleApi::boot();
+ $this->recoverLateEvents();
});
}
/**
- * Register domain contracts with their infrastructure implementations.
+ * Choose the driver of the site, WP-Cron when config/schedule.php names one that does not exist (pollora:doctor says so).
*/
- private function registerDomainContracts(): void
+ private function chooseDriver(Repository $config): void
{
- $this->app->singleton(SchedulerInterface::class, Scheduler::class);
- $this->app->singleton(ScheduleDiscovery::class, fn (): ScheduleDiscovery => new ScheduleDiscovery);
+ $driver = $config->get('schedule.driver', 'wp-cron');
+
+ try {
+ ScheduleApi::driver(is_string($driver) && $driver !== '' ? $driver : 'wp-cron');
+ } catch (\InvalidArgumentException) {
+ ScheduleApi::driver('wp-cron');
+ }
+
+ $protected = $config->get('schedule.protected_hooks', []);
+ ScheduleApi::engine()->protect(...array_map(strval(...), is_array($protected) ? $protected : []));
}
/**
- * Register application use cases.
+ * Register domain contracts with their infrastructure implementations.
*/
- private function registerUseCases(): void
+ private function registerDomainContracts(): void
{
- $this->app->bind(RegisterSchedulerFiltersUseCase::class, fn (Application $app): RegisterSchedulerFiltersUseCase => new RegisterSchedulerFiltersUseCase(
- $app->make(Filter::class),
- $app->make(SchedulerInterface::class)
- ));
-
+ $this->app->singleton(ScheduleDiscovery::class, fn (): ScheduleDiscovery => new ScheduleDiscovery);
+ $this->app->singleton(LaravelQueueDriver::class);
+ $this->app->singleton(LateEventRecovery::class);
}
/**
- * Schedule all recurring events.
+ * Look for events of the "laravel" driver whose job was lost, when the request ends, at most every five minutes.
*/
- protected function scheduleRecurringEvents(): void
+ private function recoverLateEvents(): void
{
- if ($this->isOrchestraTest() || defined('WP_CLI')) {
+ if (! function_exists('add_action') || ScheduleApi::engine()->driverName() !== LaravelQueueDriver::NAME) {
return;
}
- try {
- $schedule = $this->app->make(Schedule::class);
- RecurringEvent::scheduleAllEvents($schedule);
- } catch (QueryException) {
- // WordPress tables may not exist yet during initial installation
- }
- }
-
- /**
- * Check if we're running in an Orchestra test environment.
- */
- private function isOrchestraTest(): bool
- {
- return defined('LARAVEL_START') && class_exists(TestCase::class);
+ add_action('shutdown', function (): void {
+ if ($this->app->make(LaravelQueueDriver::class)->available()) {
+ $this->app->make(LateEventRecovery::class)->recover();
+ }
+ });
}
}
diff --git a/src/Schedule/UI/Console/ScheduleListCommand.php b/src/Schedule/UI/Console/ScheduleListCommand.php
new file mode 100644
index 00000000..f4b536af
--- /dev/null
+++ b/src/Schedule/UI/Console/ScheduleListCommand.php
@@ -0,0 +1,173 @@
+option('type');
+
+ if ($type !== null && ! in_array($type, ['once', 'recurring'], true)) {
+ $this->error('--type is "once" or "recurring".');
+
+ return self::FAILURE;
+ }
+
+ if (! function_exists('_get_cron_array')) {
+ $this->error('WordPress is not loaded.');
+
+ return self::FAILURE;
+ }
+
+ $rows = $this->rows(is_string($type) ? $type : null, is_string($this->option('driver')) ? $this->option('driver') : null);
+
+ if ($this->option('json')) {
+ $this->line((string) json_encode($rows, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
+
+ return self::SUCCESS;
+ }
+
+ if ($rows === []) {
+ $this->info('No scheduled event matches.');
+
+ return self::SUCCESS;
+ }
+
+ $network = (bool) $this->option('network');
+
+ $this->table(
+ [...($network ? ['Site'] : []), 'Hook', 'Type', 'Frequency', 'Driver', 'Next run', 'Arguments'],
+ array_map(static fn (array $row): array => [
+ ...($network ? [$row['site']] : []),
+ $row['hook'],
+ $row['type'],
+ $row['frequency'],
+ $row['driver_label'],
+ $row['next_run'],
+ $row['args'] === [] ? '—' : (string) json_encode($row['args'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
+ ], $rows),
+ );
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * @return list>
+ */
+ private function rows(?string $type, ?string $driver): array
+ {
+ $sites = $this->sites();
+ $rows = [];
+
+ foreach ($sites as $site) {
+ if ($site !== null) {
+ switch_to_blog($site);
+ }
+
+ try {
+ foreach (Schedule::engine()->index() as $event) {
+ if (($type !== null && ($event->isRecurring() ? 'recurring' : 'once') !== $type) || ($driver !== null && $event->driver !== $driver)) {
+ continue;
+ }
+
+ $rows[] = $this->row($event, $site);
+ }
+ } finally {
+ if ($site !== null) {
+ restore_current_blog();
+ }
+ }
+ }
+
+ usort($rows, static fn (array $a, array $b): int => $a['timestamp'] <=> $b['timestamp']);
+
+ return $rows;
+ }
+
+ /**
+ * The sites to read: the current one (null), one by ID, or the whole network.
+ *
+ * @return list
+ */
+ private function sites(): array
+ {
+ if ($this->option('network') && function_exists('is_multisite') && is_multisite()) {
+ return array_map(intval(...), (array) get_sites(['fields' => 'ids', 'number' => 0]));
+ }
+
+ $site = $this->option('site');
+
+ return is_numeric($site) && function_exists('is_multisite') && is_multisite() ? [(int) $site] : [null];
+ }
+
+ /**
+ * @return array
+ */
+ private function row(Event $event, ?int $site): array
+ {
+ return [
+ 'site' => $site ?? (function_exists('get_current_blog_id') ? get_current_blog_id() : 1),
+ 'hook' => $event->hook,
+ 'type' => $event->isRecurring() ? 'recurring' : 'once',
+ 'recurrence' => $event->recurrence,
+ 'frequency' => $this->frequency($event),
+ 'interval' => $event->interval,
+ 'driver' => $event->driver,
+ 'driver_label' => $this->status->label($event),
+ 'timestamp' => $event->timestamp,
+ 'next_run' => function_exists('wp_date') ? (string) wp_date('Y-m-d H:i', $event->timestamp) : gmdate('Y-m-d H:i', $event->timestamp),
+ 'args' => $event->args,
+ ];
+ }
+
+ private function frequency(Event $event): string
+ {
+ if ($event->recurrence === null) {
+ return '—';
+ }
+
+ if (in_array($event->recurrence, self::BUILT_IN, true) || $event->interval === null) {
+ return $event->recurrence;
+ }
+
+ foreach ([86400 => ' day', 3600 => ' hour', 60 => ' min'] as $seconds => $unit) {
+ if ($event->interval % $seconds === 0) {
+ $count = intdiv($event->interval, $seconds);
+
+ return $count.$unit.($count > 1 && $seconds !== 60 ? 's' : '');
+ }
+ }
+
+ return $event->interval.' s';
+ }
+}
diff --git a/src/Schedule/UI/Console/ScheduleMigrateCommand.php b/src/Schedule/UI/Console/ScheduleMigrateCommand.php
new file mode 100644
index 00000000..ae86b382
--- /dev/null
+++ b/src/Schedule/UI/Console/ScheduleMigrateCommand.php
@@ -0,0 +1,99 @@
+error('WordPress is not loaded.');
+
+ return self::FAILURE;
+ }
+
+ $to = is_string($this->option('to')) && $this->option('to') !== '' ? $this->option('to') : $this->status->active();
+ $from = is_string($this->option('from')) && $this->option('from') !== '' ? $this->option('from') : $this->guessSource($to);
+
+ if ($from === null) {
+ $this->error('No other driver keeps events: say which one with --from.');
+
+ return self::FAILURE;
+ }
+
+ try {
+ $report = (new Migrator(Schedule::engine()))->migrate($from, $to, (bool) $this->option('dry-run'));
+ } catch (\Throwable $throwable) {
+ $this->error($throwable->getMessage());
+
+ return self::FAILURE;
+ }
+
+ $verb = $report->dryRun ? 'Would move' : 'Moved';
+
+ foreach ($report->moved as $event) {
+ $this->line(sprintf('%s %s (%s)', $verb, $event->hook, $event->recurrence ?? 'once'));
+ }
+
+ foreach ($report->skipped as $skipped) {
+ $this->line(sprintf('Kept %s: %s', $skipped['event']->hook, $skipped['reason']));
+ }
+
+ foreach ($report->failed as $failed) {
+ $this->warn(sprintf('Could not move %s: %s', $failed['event']->hook, $failed['reason']));
+ }
+
+ $summary = sprintf('%s %d event(s) from %s to %s%s.', $verb, count($report->moved), $from, $to, $report->dryRun ? ' (dry run)' : '');
+
+ if (! $report->succeeded()) {
+ $this->error($summary.' Some events stayed where they were.');
+
+ return self::FAILURE;
+ }
+
+ $this->info($summary);
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * WP-Cron holds what a driver has not taken; when the target is WP-Cron, the driver that still holds events.
+ */
+ private function guessSource(string $to): ?string
+ {
+ if ($to !== CronIndex::WP_CRON) {
+ return CronIndex::WP_CRON;
+ }
+
+ foreach (Schedule::engine()->index() as $event) {
+ if ($event->driver !== CronIndex::WP_CRON) {
+ return (string) $event->driver;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/Schedule/UI/Console/ScheduleRecoverCommand.php b/src/Schedule/UI/Console/ScheduleRecoverCommand.php
new file mode 100644
index 00000000..ef195176
--- /dev/null
+++ b/src/Schedule/UI/Console/ScheduleRecoverCommand.php
@@ -0,0 +1,58 @@
+option('network') && function_exists('is_multisite') && is_multisite()
+ ? array_map(static fn ($site): int => (int) $site->blog_id, get_sites(['fields' => 'objects', 'number' => 0]))
+ : [null];
+
+ $total = 0;
+
+ foreach ($sites as $site) {
+ if ($site !== null) {
+ switch_to_blog($site);
+ }
+
+ try {
+ $total += $this->option('dry-run') ? $this->list($recovery, $site) : $recovery->recover(force: true);
+ } finally {
+ if ($site !== null) {
+ restore_current_blog();
+ }
+ }
+ }
+
+ $this->info(sprintf($this->option('dry-run') ? '%d late event(s).' : '%d event(s) sent again.', $total));
+
+ return self::SUCCESS;
+ }
+
+ private function list(LateEventRecovery $recovery, ?int $site): int
+ {
+ $late = $recovery->late();
+
+ foreach ($late as $event) {
+ $this->line(sprintf('%s%s %s', $site === null ? '' : sprintf('[site %d] ', $site), gmdate('Y-m-d H:i:s', $event->timestamp), $event->hook));
+ }
+
+ return count($late);
+ }
+}
diff --git a/src/Support/Slug.php b/src/Support/Slug.php
index 7d0ae61a..94a1c9a9 100644
--- a/src/Support/Slug.php
+++ b/src/Support/Slug.php
@@ -4,6 +4,8 @@
namespace Pollora\Support;
+use Pollora\Attributes\Support\Naming;
+
/**
* Utility class for slug generation.
*
@@ -25,15 +27,7 @@ class Slug
*/
public static function fromClassName(string $className): string
{
- // Extract the base class name (remove namespace)
- $baseName = basename(str_replace('\\', '/', $className));
-
- // Remove "Command" suffix if present
- if (self::endsWith($baseName, 'Command')) {
- $baseName = substr($baseName, 0, -7);
- }
-
- return self::toKebabCase($baseName);
+ return Naming::commandFromClassName($className);
}
/**
@@ -50,7 +44,7 @@ public static function fromClassName(string $className): string
*/
public static function fromMethodName(string $methodName): string
{
- return self::toKebabCase($methodName);
+ return Naming::slug($methodName);
}
/**
@@ -61,35 +55,6 @@ public static function fromMethodName(string $methodName): string
*/
public static function toKebabCase(string $string): string
{
- // Insert a hyphen before any uppercase letter that follows a lowercase letter or number
- $string = preg_replace('/([a-z0-9])([A-Z])/', '$1-$2', $string);
-
- // Convert to lowercase
- $string = strtolower((string) $string);
-
- // Replace multiple consecutive hyphens with a single hyphen
- $string = preg_replace('/-+/', '-', $string);
-
- // Trim hyphens from the beginning and end
- $string = trim((string) $string, '-');
-
- return $string;
- }
-
- /**
- * Check if a string ends with a specific suffix.
- *
- * @param string $haystack The string to check
- * @param string $needle The suffix to look for
- * @return bool True if the string ends with the suffix
- */
- private static function endsWith(string $haystack, string $needle): bool
- {
- $length = strlen($needle);
- if ($length === 0) {
- return true;
- }
-
- return substr($haystack, -$length) === $needle;
+ return Naming::slug($string);
}
}
diff --git a/src/Support/WpGlobals.php b/src/Support/WpGlobals.php
deleted file mode 100755
index 7f4c22d1..00000000
--- a/src/Support/WpGlobals.php
+++ /dev/null
@@ -1,90 +0,0 @@
-
- */
- private static array $essentialGlobals = [
- 'current_user',
- 'wpdb',
- 'wp_roles',
- 'wp',
- 'wp_query',
- 'post',
- 'pagenow',
- 'hook_suffix',
- 'wp_filter',
- 'wp_actions',
- 'wp_current_filter',
- ];
-
- /**
- * Wraps a callback function with WordPress globals.
- *
- * @param callable $callback The function to wrap
- * @param array|null $globals Optional specific list of globals to preserve
- * @return callable The wrapped function with access to WordPress globals
- */
- public static function wrap(callable $callback, ?array $globals = null): callable
- {
- // If $globals is null, use the default list
- $globalKeys = $globals ?? self::$essentialGlobals;
-
- // Capture the global variables
- $capturedGlobals = array_intersect_key($GLOBALS, array_flip($globalKeys));
-
- return static function (...$args) use ($callback, $capturedGlobals) {
- // Reinject the globals into the context
- foreach ($capturedGlobals as $key => $value) {
- $GLOBALS[$key] = $value;
- }
-
- // Execute the callback
- return $callback(...$args);
- };
- }
-
- /**
- * Adds a global to the list of essential globals.
- *
- * @param string $globalName Name of the global variable to add
- */
- public static function addEssentialGlobal(string $globalName): void
- {
- if (! in_array($globalName, self::$essentialGlobals)) {
- self::$essentialGlobals[] = $globalName;
- }
- }
-
- /**
- * Sets multiple essential globals at once.
- *
- * @param array $globalNames An array of global variable names
- */
- public static function setEssentialGlobals(array $globalNames): void
- {
- self::$essentialGlobals = $globalNames;
- }
-
- /**
- * Gets the current list of essential globals.
- *
- * @return array The list of essential global variable names
- */
- public static function getEssentialGlobals(): array
- {
- return self::$essentialGlobals;
- }
-}
diff --git a/src/Taxonomy/Infrastructure/Services/TaxonomyConfiguration.php b/src/Taxonomy/Infrastructure/Services/TaxonomyConfiguration.php
index c6acae9b..0a289bc8 100644
--- a/src/Taxonomy/Infrastructure/Services/TaxonomyConfiguration.php
+++ b/src/Taxonomy/Infrastructure/Services/TaxonomyConfiguration.php
@@ -96,6 +96,16 @@ public function getArg(string $key, mixed $default = null): mixed
return $this->attributeArgs[$key] ?? $default;
}
+ /**
+ * Replace every attribute argument.
+ *
+ * @param array $args The arguments to keep
+ */
+ public function replaceArgs(array $args): void
+ {
+ $this->attributeArgs = $args;
+ }
+
/**
* Merge additional arguments into the configuration.
*
diff --git a/src/Taxonomy/Infrastructure/Services/TaxonomyDiscovery.php b/src/Taxonomy/Infrastructure/Services/TaxonomyDiscovery.php
index 2cb6ca82..c5f4880e 100644
--- a/src/Taxonomy/Infrastructure/Services/TaxonomyDiscovery.php
+++ b/src/Taxonomy/Infrastructure/Services/TaxonomyDiscovery.php
@@ -5,6 +5,9 @@
namespace Pollora\Taxonomy\Infrastructure\Services;
use Illuminate\Support\Str;
+use Pollora\Attributes\Contracts\ConfiguresArguments;
+use Pollora\Attributes\Contracts\ConfiguresArgumentsWithMethod;
+use Pollora\Attributes\Contracts\HandlesAttributes;
use Pollora\Attributes\Taxonomy;
use Pollora\Discovery\Domain\Contracts\ConfigurableDiscoveryInterface;
use Pollora\Discovery\Domain\Contracts\DiscoveryInterface;
@@ -307,8 +310,13 @@ private function processClassAttribute(ReflectionClass $reflectionClass, \Reflec
{
$attributeInstance = $attribute->newInstance();
- // Check if the attribute has a handle method and call it
- if (method_exists($attributeInstance, 'handle')) {
+ if ($attributeInstance instanceof ConfiguresArguments) {
+ $config->replaceArgs($attributeInstance->configureArguments($config->withArgs()));
+
+ return;
+ }
+
+ if ($attributeInstance instanceof HandlesAttributes) {
$attributeInstance->handle(app(), $config, $reflectionClass, $attributeInstance);
}
}
@@ -330,8 +338,13 @@ private function processMethodAttribute(
): void {
$attributeInstance = $attribute->newInstance();
- // Check if the attribute has a handle method and call it
- if (method_exists($attributeInstance, 'handle')) {
+ if ($attributeInstance instanceof ConfiguresArgumentsWithMethod) {
+ $config->replaceArgs($attributeInstance->configureArguments($config->withArgs(), [new $method->class, $method->getName()]));
+
+ return;
+ }
+
+ if ($attributeInstance instanceof HandlesAttributes) {
$attributeInstance->handle(app(), $config, $method, $attributeInstance);
}
}
diff --git a/src/WordPress/Bootstrap.php b/src/WordPress/Bootstrap.php
index 80f908e2..f3c4554b 100644
--- a/src/WordPress/Bootstrap.php
+++ b/src/WordPress/Bootstrap.php
@@ -396,6 +396,15 @@ private function defineWordPressConstants(): void
Constant::queue('JETPACK_DEV_DEBUG', $debugMode);
+ if (! function_exists('getenv') || getenv('WP_ENVIRONMENT_TYPE') === false) {
+ Constant::queue('WP_ENVIRONMENT_TYPE', match (config('app.env')) {
+ 'local' => 'local',
+ 'dev', 'development', 'testing' => 'development',
+ 'staging' => 'staging',
+ default => 'production',
+ });
+ }
+
foreach ((array) config('wordpress.constants', []) as $key => $value) {
$key = strtoupper((string) $key);
Constant::queue($key, $value);
diff --git a/src/WpCli/Application/Services/WpCliService.php b/src/WpCli/Application/Services/WpCliService.php
deleted file mode 100644
index 3f52a2f0..00000000
--- a/src/WpCli/Application/Services/WpCliService.php
+++ /dev/null
@@ -1,118 +0,0 @@
-
- */
- private array $registeredCommands = [];
-
- public function __construct(
- private readonly WpCliAdapter $wpCliAdapter,
- private readonly ?LoggerInterface $logger = null
- ) {}
-
- /**
- * Register a WP CLI command.
- *
- * @param string $name The command name
- * @param string|array $className The command class name or callable array
- * @param string $description The command description
- * @param int $priority The registration priority
- * @param array $args Additional WP_CLI::add_command() arguments
- */
- public function register(string $name, string|array $className, string $description = '', int $priority = 0, array $args = []): void
- {
- $this->registeredCommands[$name] = [
- 'class' => $className,
- 'description' => $description,
- 'priority' => $priority,
- 'args' => $args,
- ];
-
- // Register immediately if WP CLI is available
- $this->registerWithWpCli($name, $className, $args);
- }
-
- /**
- * Register a command with WP CLI through the adapter.
- *
- * @param string $name The command name
- * @param string|array $className The command class name or callable array
- * @param array $args Additional WP_CLI::add_command() arguments
- */
- private function registerWithWpCli(string $name, string|array $className, array $args = []): void
- {
- if (! $this->wpCliAdapter->isAvailable()) {
- return;
- }
-
- try {
- // Delegate validation and registration to the adapter
- $this->wpCliAdapter->addCommand($name, $className, $args);
- } catch (\Throwable $throwable) {
- $this->logger?->error(sprintf('Failed to register WP CLI command %s: ', $name).$throwable->getMessage());
- }
- }
-
- /**
- * Initialize all registered commands.
- * This method can be called to ensure all commands are properly initialized.
- */
- public function initializeCommands(): void
- {
- if (! $this->wpCliAdapter->isAvailable()) {
- return;
- }
-
- foreach ($this->registeredCommands as $name => $commandData) {
- $this->registerWithWpCli($name, $commandData['class'], $commandData['args']);
- }
- }
-
- /**
- * Get all registered commands.
- *
- * @return array
- */
- public function getRegisteredCommands(): array
- {
- return $this->registeredCommands;
- }
-
- /**
- * Check if a command is registered.
- *
- * @param string $name The command name
- */
- public function hasCommand(string $name): bool
- {
- return isset($this->registeredCommands[$name]);
- }
-
- /**
- * Get command information.
- *
- * @param string $name The command name
- * @return array{class: string|array, description: string, priority: int, args: array}|null
- */
- public function getCommand(string $name): ?array
- {
- return $this->registeredCommands[$name] ?? null;
- }
-}
diff --git a/src/WpCli/Domain/Contracts/WpCliCommandInterface.php b/src/WpCli/Domain/Contracts/WpCliCommandInterface.php
deleted file mode 100644
index eb1c2c21..00000000
--- a/src/WpCli/Domain/Contracts/WpCliCommandInterface.php
+++ /dev/null
@@ -1,43 +0,0 @@
- $arguments The command arguments
- * @param array $options The command options/flags
- */
- public function __invoke(array $arguments, array $options): void;
-
- /**
- * Get the command priority for registration order.
- *
- * @return int The priority (higher numbers = higher priority)
- */
- public function getPriority(): int;
-
- /**
- * Initialize the command.
- * This method is called during the discovery and registration process.
- */
- public function initialize(): void;
-
- /**
- * Get the default command name from the AsWordpressCommand attribute.
- *
- * @return string|null The command name or null if not found
- */
- public static function getDefaultName(): ?string;
-}
diff --git a/src/WpCli/Domain/Models/WordpressCommand.php b/src/WpCli/Domain/Models/WordpressCommand.php
deleted file mode 100644
index e9e84c35..00000000
--- a/src/WpCli/Domain/Models/WordpressCommand.php
+++ /dev/null
@@ -1,122 +0,0 @@
- $arguments The command arguments
- * @param array $options The command options/flags
- */
- abstract public function __invoke(array $arguments, array $options): void;
-
- /**
- * Get the command priority for registration order.
- * Override this method to change the registration priority.
- *
- * @return int The priority (higher numbers = higher priority)
- */
- public function getPriority(): int
- {
- return 0;
- }
-
- /**
- * Get command registration data.
- * Returns data needed for command registration without directly interacting with WP-CLI.
- * This respects the hexagonal architecture by keeping the domain layer pure.
- *
- * @return array{name: string|null, description: string|null, class: string}
- */
- public function getRegistrationData(): array
- {
- return [
- 'name' => static::getDefaultName(),
- 'description' => static::getDefaultDescription(),
- 'class' => static::class,
- ];
- }
-
- /**
- * Get the default command name from the WpCli attribute.
- *
- * @return string|null The command name or null if not found
- */
- public static function getDefaultName(): ?string
- {
- $class = static::class;
-
- try {
- $reflectionClass = new \ReflectionClass($class);
- $attributes = $reflectionClass->getAttributes(WpCli::class);
-
- if ($attributes !== []) {
- /** @var WpCli $attribute */
- $attribute = $attributes[0]->newInstance();
-
- return $attribute->getCommandName($class);
- }
- } catch (\ReflectionException) {
- // Reflection failure is handled by returning null
- }
-
- return null;
- }
-
- /**
- * Get the default command description from the WpCli attribute.
- *
- * @return string|null The command description or null if not found
- */
- public static function getDefaultDescription(): ?string
- {
- $class = static::class;
-
- try {
- $reflectionClass = new \ReflectionClass($class);
- $attributes = $reflectionClass->getAttributes(WpCli::class);
-
- if ($attributes !== []) {
- /** @var WpCli $attribute */
- $attribute = $attributes[0]->newInstance();
-
- return null;
- }
- } catch (\ReflectionException) {
- // Reflection failure is handled by returning null
- }
-
- return null;
- }
-
- /**
- * Get the command description from the WpCli attribute.
- * This method is called by WP CLI to get the command description.
- *
- * @return string The command description
- */
- public static function get_shortdesc(): string
- {
- return static::getDefaultDescription() ?? '';
- }
-}
diff --git a/src/WpCli/Infrastructure/Adapters/WpCliAdapter.php b/src/WpCli/Infrastructure/Adapters/WpCliAdapter.php
deleted file mode 100644
index aae45f5c..00000000
--- a/src/WpCli/Infrastructure/Adapters/WpCliAdapter.php
+++ /dev/null
@@ -1,144 +0,0 @@
- $args Additional arguments for WP-CLI
- *
- * @throws \RuntimeException If WP-CLI is not available
- * @throws \InvalidArgumentException If the handler is invalid
- */
- public function addCommand(string $name, string|array|object $handler, array $args = []): void
- {
- if (! $this->isAvailable()) {
- throw new \RuntimeException('WP-CLI is not available');
- }
-
- $this->validateHandler($handler);
-
- WP_CLI::add_command($name, $handler, $args);
- }
-
- /**
- * Validate that the handler is properly formatted.
- *
- * @throws \InvalidArgumentException If the handler is invalid
- */
- private function validateHandler(string|array|object $handler): void
- {
- if (is_string($handler)) {
- if (! class_exists($handler)) {
- throw new \InvalidArgumentException(sprintf("Command handler class '%s' does not exist", $handler));
- }
- } elseif (is_array($handler)) {
- if (count($handler) !== 2) {
- throw new \InvalidArgumentException('Command handler array must contain exactly 2 elements [object, method]');
- }
-
- [$object, $method] = $handler;
-
- if (! is_object($object)) {
- throw new \InvalidArgumentException('First element of handler array must be an object');
- }
-
- if (! is_string($method)) {
- throw new \InvalidArgumentException('Second element of handler array must be a method name string');
- }
-
- if (! method_exists($object, $method)) {
- $class = $object::class;
- throw new \InvalidArgumentException(sprintf("Method '%s' does not exist on class '%s'", $method, $class));
- }
- } elseif (is_object($handler)) {
- if (! method_exists($handler, '__invoke')) {
- $class = $handler::class;
- throw new \InvalidArgumentException(sprintf("Object of class '%s' must be invokable (have __invoke method)", $class));
- }
- }
- }
-
- /**
- * Check if a command is already registered with WP-CLI.
- *
- * @param string $name The command name
- * @return bool True if the command exists
- */
- public function hasCommand(string $name): bool
- {
- if (! $this->isAvailable()) {
- return false;
- }
-
- try {
- $runner = WP_CLI::get_runner();
-
- return $runner->find_command_to_run(explode(' ', $name)) !== null;
- } catch (\Throwable) {
- return false;
- }
- }
-
- /**
- * Get WP-CLI version information.
- *
- * @return string|null The WP-CLI version or null if not available
- */
- public function getVersion(): ?string
- {
- if (! $this->isAvailable()) {
- return null;
- }
-
- return WP_CLI_VERSION ?? null;
- }
-
- /**
- * Log a message to WP-CLI output.
- *
- * @param string $message The message to log
- * @param string $level The log level (debug, log, warning, error, success)
- */
- public function log(string $message, string $level = 'log'): void
- {
- if (! $this->isAvailable()) {
- return;
- }
-
- try {
- match ($level) {
- 'debug' => WP_CLI::debug($message),
- 'warning' => WP_CLI::warning($message),
- 'error' => WP_CLI::error($message),
- 'success' => WP_CLI::success($message),
- default => WP_CLI::log($message),
- };
- } catch (\Throwable) {
- // Silently fail for logging to avoid breaking command execution
- }
- }
-}
diff --git a/src/WpCli/Infrastructure/Adapters/WpCliMethodWrapper.php b/src/WpCli/Infrastructure/Adapters/WpCliMethodWrapper.php
deleted file mode 100644
index f0c8430b..00000000
--- a/src/WpCli/Infrastructure/Adapters/WpCliMethodWrapper.php
+++ /dev/null
@@ -1,81 +0,0 @@
-docComment = $this->originalMethod->getDocComment() ?: '';
- }
-
- public function __invoke(array $args, array $assocArgs): mixed
- {
- // Delegate to the original method
- return $this->originalMethod->invoke($this->instance, $args, $assocArgs);
- }
-
- /**
- * Magic method to expose the original method name when WP-CLI reflects on this object
- * This allows WP-CLI to find a method with the same name as the original.
- */
- public function __call(string $name, array $arguments): mixed
- {
- if ($name === $this->originalMethod->getName()) {
- return $this->originalMethod->invoke($this->instance, ...$arguments);
- }
-
- throw new \BadMethodCallException(sprintf('Method %s does not exist', $name));
- }
-
- /**
- * Creates a dynamic method with the original method's name and documentation.
- * This method will have the same name and docblock as the original method,
- * allowing WP-CLI to properly extract documentation.
- */
- public function createProxyMethod(): ReflectionMethod
- {
- return $this->originalMethod;
- }
-
- /**
- * Get the original method for reflection purposes
- */
- public function getOriginalMethod(): ReflectionMethod
- {
- return $this->originalMethod;
- }
-
- /**
- * Get the original instance
- */
- public function getOriginalInstance(): object
- {
- return $this->instance;
- }
-
- /**
- * Get the cached docblock
- */
- public function getDocComment(): string|false
- {
- return $this->docComment ?: false;
- }
-}
diff --git a/src/WpCli/Infrastructure/Services/WpCliDiscovery.php b/src/WpCli/Infrastructure/Services/WpCliDiscovery.php
deleted file mode 100644
index 36c9baeb..00000000
--- a/src/WpCli/Infrastructure/Services/WpCliDiscovery.php
+++ /dev/null
@@ -1,321 +0,0 @@
-
- */
- private array $commandInstances = [];
-
- public function __construct(
- private readonly WpCliService $wpCliService,
- private readonly ?LoggerInterface $logger = null
- ) {}
-
- /**
- * {@inheritDoc}
- */
- public function discover(DiscoveryLocationInterface $location, DiscoveredStructure $structure, ?ReflectionCacheInterface $reflectionCache = null): void
- {
- if (! $structure instanceof DiscoveredClass || $structure->isAbstract) {
- return;
- }
-
- foreach ($structure->attributes as $attribute) {
- if ($attribute->class === WpCli::class) {
- $this->getItems()->add($location, [
- 'class' => $structure->namespace.'\\'.$structure->name,
- 'reflection_cache' => $reflectionCache,
- ]);
-
- return;
- }
- }
- }
-
- /**
- * {@inheritDoc}
- */
- public function apply(): void
- {
- foreach ($this->getItems() as $discoveredItem) {
- try {
- $reflectionCache = $discoveredItem['reflection_cache'] ?? null;
- $this->processWpCliCommand($discoveredItem['class'], $reflectionCache);
- } catch (\Throwable $e) {
- $this->logger?->error(sprintf('Failed to register WP CLI command from class %s: ', $discoveredItem['class']).$e->getMessage());
- }
- }
- }
-
- /**
- * Process a WP CLI command class for registration.
- */
- private function processWpCliCommand(string $className, ?ReflectionCacheInterface $reflectionCache = null): void
- {
- $reflectionClass = $reflectionCache->getClassReflection($className);
-
- if (! $reflectionClass->isInstantiable()) {
- return;
- }
-
- $attributes = $reflectionClass->getAttributes(WpCli::class);
- if ($attributes === []) {
- return;
- }
-
- /** @var WpCli $attribute */
- $attribute = $attributes[0]->newInstance();
- $commandName = $attribute->getCommandName($className);
-
- if (empty($commandName)) {
- $this->logger?->error(sprintf('WP CLI command %s has no command name defined', $className));
-
- return;
- }
-
- // On garde quand même l'instance dispo pour les subcommands
- $instance = $this->getCommandInstance($className);
-
- if ($this->hasSubcommands($reflectionClass)) {
- $this->processSubcommands($reflectionClass, $className, $commandName, $instance);
- } else {
- // IMPORTANT : on repasse au class-string, pas à l'instance
- $this->registerCommand(
- $commandName,
- $className,
- $this->collectWpCliArguments($reflectionClass)
- );
- }
- }
-
- /**
- * Retourne une instance unique de la classe de commande.
- *
- * @param class-string $className
- */
- private function getCommandInstance(string $className): object
- {
- // Use instance pool if available, otherwise fallback to local cache
- return $this->getInstanceFromPool($className, function () use ($className): object {
- // On laisse le container gérer la construction
- $this->commandInstances[$className] ??= resolve($className);
-
- return $this->commandInstances[$className];
- });
- }
-
- /**
- * Check if the class has subcommands (no __invoke and has #[Command] methods).
- */
- private function hasSubcommands(ReflectionClass $reflectionClass): bool
- {
- if ($reflectionClass->hasMethod('__invoke')) {
- return false;
- }
-
- foreach ($reflectionClass->getMethods() as $method) {
- if ($method->getAttributes(Command::class) !== []) {
- return true;
- }
- }
-
- return false;
- }
-
- /**
- * Process and register subcommands for a class.
- */
- private function processSubcommands(
- ReflectionClass $reflectionClass,
- string $className,
- string $baseCommandName,
- object $instance
- ): void {
- // Base command => class-string, comme avant
- $this->registerCommand(
- $baseCommandName,
- $className,
- $this->collectWpCliArguments($reflectionClass)
- );
-
- // Subcommands => [instance, méthode]
- foreach ($reflectionClass->getMethods() as $method) {
- $commandAttributes = $method->getAttributes(Command::class);
- if ($commandAttributes === []) {
- continue;
- }
-
- /** @var Command $commandAttribute */
- $commandAttribute = $commandAttributes[0]->newInstance();
- $subcommandName = $commandAttribute->getSubcommandName($method->getName());
- $fullCommandName = sprintf('%s %s', $baseCommandName, $subcommandName);
-
- $handler = $this->createCallable($instance, $method);
-
- $this->registerCommand(
- $fullCommandName,
- $handler,
- $this->collectWpCliArguments($method)
- );
- }
- }
-
- /**
- * Register a command through the WP CLI service only.
- * This ensures single responsibility and avoids duplication.
- *
- * @param array $args
- */
- private function registerCommand(string $commandName, string|array|\Closure $handler, array $args = []): void
- {
- // Delegate to the application service which handles WP-CLI registration
- $this->wpCliService->register($commandName, $handler, '', 0, $args);
- }
-
- /**
- * Create a callable for a method (handles private/protected methods).
- */
- private function createCallable(object $instance, ReflectionMethod $method): array|\Closure
- {
- if ($method->isPublic()) {
- // Cas simple : WP-CLI peut appeler directement [instance, 'methodName']
- return [$instance, $method->getName()];
- }
-
- // Créer un wrapper qui se comporte comme la méthode originale
- $wrapper = new WpCliMethodWrapper($instance, $method);
-
- // Retourner un callable qui préserve l'accès à la documentation
- return $wrapper->__invoke(...);
- }
-
- /**
- * Collect WP CLI arguments from reflection attributes.
- */
- private function collectWpCliArguments(ReflectionClass|ReflectionMethod $reflection): array
- {
- $args = [];
-
- $attributeMap = [
- BeforeInvoke::class => ['callback', 'before_invoke'],
- AfterInvoke::class => ['callback', 'after_invoke'],
- Synopsis::class => ['synopsis', 'synopsis'],
- When::class => ['hook', 'when'],
- IsDeferred::class => ['deferred', 'is_deferred'],
- ];
-
- foreach ($attributeMap as $attributeClass => [$property, $wpCliKey]) {
- $attributes = $reflection->getAttributes($attributeClass);
- if ($attributes !== []) {
- $args[$wpCliKey] = $attributes[0]->newInstance()->$property;
- }
- }
-
- // Si c'est une méthode, extraire la documentation du docblock
- if ($reflection instanceof ReflectionMethod) {
- $docComment = $reflection->getDocComment();
- if ($docComment) {
- // Extraire la description courte et longue du docblock
- $description = $this->extractMethodDescription($docComment);
- $args['shortdesc'] = $description['short'];
-
- if (! empty($description['long'])) {
- $args['longdesc'] = $description['long'];
- }
- }
- }
-
- return $args;
- }
-
- /**
- * Extract description from method docblock for WP-CLI help.
- */
- private function extractMethodDescription(string $docComment): array
- {
- // Remove /** and */ and leading asterisks
- $cleaned = preg_replace('/^\/\*\*|\*\/$/', '', $docComment);
- $lines = explode("\n", (string) $cleaned);
-
- $description = ['short' => '', 'long' => ''];
- $inLongDesc = false;
- $longDescLines = [];
-
- foreach ($lines as $line) {
- $line = trim(ltrim($line, ' *'));
-
- // Skip empty lines at the beginning
- if (($line === '' || $line === '0') && empty($description['short']) && $longDescLines === []) {
- continue;
- }
-
- // Stop at @tags
- if (str_starts_with($line, '@') || str_starts_with($line, '##')) {
- break;
- }
-
- // First non-empty line is the short description
- if (empty($description['short']) && ($line !== '' && $line !== '0')) {
- $description['short'] = $line;
-
- continue;
- }
-
- // After short description, collect long description
- if ($description['short'] !== '' && $description['short'] !== '0') {
- $inLongDesc = true;
- if ($line !== '' && $line !== '0' || $longDescLines !== []) {
- $longDescLines[] = $line;
- }
- }
- }
-
- if ($longDescLines !== []) {
- $description['long'] = trim(implode("\n", $longDescLines));
- }
-
- return $description;
- }
-
- /**
- * {@inheritDoc}
- */
- public function getIdentifier(): string
- {
- return 'wp_cli_commands';
- }
-}
diff --git a/src/WpRest/Infrastructure/Services/WpRestAttributableWrapper.php b/src/WpRest/Infrastructure/Services/WpRestAttributableWrapper.php
deleted file mode 100644
index cca261ca..00000000
--- a/src/WpRest/Infrastructure/Services/WpRestAttributableWrapper.php
+++ /dev/null
@@ -1,62 +0,0 @@
-realInstance = $this->createRealInstance();
- }
-
- /**
- * Get the real instance of the wrapped class.
- */
- public function getRealInstance(): mixed
- {
- return $this->realInstance;
- }
-
- /**
- * Create an instance of the real class.
- */
- private function createRealInstance(): mixed
- {
- try {
- $reflectionClass = $this->reflectionCache->getClassReflection($this->className);
-
- if ($reflectionClass->isInstantiable()) {
- return $reflectionClass->newInstance();
- }
- } catch (\Throwable $throwable) {
- try {
- resolve(LoggerInterface::class)->error(
- sprintf('Failed to create instance of %s: ', $this->className).$throwable->getMessage()
- );
- } catch (\Throwable) {
- }
- }
-
- return null;
- }
-}
diff --git a/src/WpRest/Infrastructure/Services/WpRestDiscovery.php b/src/WpRest/Infrastructure/Services/WpRestDiscovery.php
deleted file mode 100644
index 62e89c78..00000000
--- a/src/WpRest/Infrastructure/Services/WpRestDiscovery.php
+++ /dev/null
@@ -1,230 +0,0 @@
-isAbstract) {
- return;
- }
-
- // Check if class has WpRestRoute attribute
- $wpRestRouteAttribute = $this->findWpRestRouteAttribute($structure->attributes);
- if ($wpRestRouteAttribute === null) {
- return;
- }
-
- // Collect the class for registration
- $this->getItems()->add($location, [
- 'class' => $structure->namespace.'\\'.$structure->name,
- 'attribute' => $wpRestRouteAttribute,
- 'structure' => $structure,
- 'reflection_cache' => $reflectionCache,
- ]);
- }
-
- /**
- * {@inheritDoc}
- *
- * Applies discovered WpRestRoute classes by registering them as REST API endpoints.
- * Each discovered class is processed for method-level attributes.
- */
- public function apply(): void
- {
- foreach ($this->getItems() as $discoveredItem) {
- [
- 'class' => $className,
- 'attribute' => $restRouteAttribute,
- 'structure' => $structure
- ] = $discoveredItem;
-
- try {
- // Process the complete WP REST route configuration
- $reflectionCache = $discoveredItem['reflection_cache'] ?? null;
- $this->processWpRestRoute($className, $reflectionCache);
- } catch (\Throwable $e) {
- // Log the error but continue with other REST routes
- $this->logger?->error(sprintf('Failed to register WP REST route from class %s: ', $className).$e->getMessage());
- }
- }
- }
-
- /**
- * Find WpRestRoute attribute in the given attributes array.
- *
- * @param array $attributes The attributes to search through
- * @return object|null The WpRestRoute attribute or null if not found
- */
- private function findWpRestRouteAttribute(array $attributes): ?object
- {
- foreach ($attributes as $attribute) {
- if ($attribute->class === WpRestRoute::class) {
- return $attribute;
- }
- }
-
- return null;
- }
-
- /**
- * Process a complete WP REST route configuration from its class and attributes.
- *
- * @param string $className The fully qualified class name
- * @param ReflectionCacheInterface|null $reflectionCache Optional reflection cache
- */
- private function processWpRestRoute(string $className, ?ReflectionCacheInterface $reflectionCache = null): void
- {
- try {
- $reflectionClass = $reflectionCache->getClassReflection($className);
- $wpRestRouteAttributes = $reflectionCache->getClassAttributes($className, WpRestRoute::class);
-
- if ($wpRestRouteAttributes === []) {
- return;
- }
-
- /** @var WpRestRoute $wpRestRoute */
- $wpRestRoute = $wpRestRouteAttributes[0]->newInstance();
-
- // Create wrapper once for the class (use cache to avoid recreating)
- $wrapperKey = md5($className.$wpRestRoute->namespace.$wpRestRoute->route);
-
- $this->wrapperCache[$wrapperKey] ??= new WpRestAttributableWrapper(
- $className,
- $wpRestRoute->namespace,
- $wpRestRoute->route,
- $wpRestRoute->permissionCallback,
- $reflectionCache
- );
-
- $attributableWrapper = $this->wrapperCache[$wrapperKey];
-
- // Process all method-level attributes
- $this->processMethodLevelAttributes($className, $attributableWrapper, $reflectionCache);
-
- } catch (\ReflectionException $reflectionException) {
- $this->logger?->error(sprintf('Failed to process WP REST route for class %s: ', $className).$reflectionException->getMessage());
- }
- }
-
- /**
- * Process method-level attributes to register REST endpoints.
- *
- * @param string $className The fully qualified class name
- * @param WpRestAttributableWrapper $attributableWrapper The wrapper instance
- * @param ReflectionCacheInterface|null $reflectionCache Optional reflection cache
- */
- private function processMethodLevelAttributes(string $className, WpRestAttributableWrapper $attributableWrapper, ?ReflectionCacheInterface $reflectionCache = null): void
- {
- try {
- $methods = $reflectionCache->getPublicMethods($className);
-
- foreach ($methods as $method) {
- $this->processMethodAttributes($method, $attributableWrapper);
- }
- } catch (\ReflectionException $reflectionException) {
- $this->logger?->error(sprintf('Failed to process method-level attributes for %s: ', $className).$reflectionException->getMessage());
- }
- }
-
- /**
- * Process all attributes for a single method.
- *
- * @param ReflectionMethod $method The method to process
- * @param WpRestAttributableWrapper $attributableWrapper The wrapper instance
- */
- private function processMethodAttributes(ReflectionMethod $method, WpRestAttributableWrapper $attributableWrapper): void
- {
- foreach ($method->getAttributes() as $attribute) {
- // Process attributes in the WpRestRoute namespace
- if (! str_contains($attribute->getName(), 'Pollora\\Attributes\\WpRestRoute\\')) {
- continue;
- }
-
- $this->processMethodAttribute($method, $attribute, $attributableWrapper);
- }
- }
-
- /**
- * Process a single method-level attribute for REST route registration.
- *
- * @param ReflectionMethod $method The method with the attribute
- * @param \ReflectionAttribute $attribute The attribute to process
- * @param WpRestAttributableWrapper $attributableWrapper The wrapper instance
- */
- private function processMethodAttribute(
- ReflectionMethod $method,
- \ReflectionAttribute $attribute,
- WpRestAttributableWrapper $attributableWrapper
- ): void {
- try {
- $attributeInstance = $attribute->newInstance();
-
- // Check if the attribute has a handle method and call it
- if (! method_exists($attributeInstance, 'handle')) {
- return;
- }
-
- // Let the attribute handle the registration
- add_action('rest_api_init', function () use ($attributeInstance, $attributableWrapper, $method): void {
- $attributeInstance->handle(app(), $attributableWrapper, $method, $attributeInstance);
- });
- } catch (\Throwable $throwable) {
- $className = $method->getDeclaringClass()->getName();
- $this->logger?->error(sprintf('Failed to process method attribute for %s::%s: ', $className, $method->getName()).$throwable->getMessage());
- }
- }
-
- /**
- * {@inheritDoc}
- */
- public function getIdentifier(): string
- {
- return 'wp_rest_routes';
- }
-}
diff --git a/src/WpRest/Permissions/Can.php b/src/WpRest/Permissions/Can.php
deleted file mode 100644
index ef9418dd..00000000
--- a/src/WpRest/Permissions/Can.php
+++ /dev/null
@@ -1,43 +0,0 @@
-capability instanceof BackedEnum ? (string) $this->capability->value : $this->capability;
- $arguments = $this->parameter === null ? [] : [$request->get_param($this->parameter)];
-
- return current_user_can($capability, ...$arguments) ?: new WP_Error(
- 'rest_forbidden',
- __('You do not have permission to access this endpoint.'),
- ['status' => rest_authorization_required_code()]
- );
- }
-}
diff --git a/src/WpRest/Permissions/IsAdmin.php b/src/WpRest/Permissions/IsAdmin.php
deleted file mode 100755
index faa42e05..00000000
--- a/src/WpRest/Permissions/IsAdmin.php
+++ /dev/null
@@ -1,29 +0,0 @@
- 403]
- );
- }
-}
diff --git a/src/WpRest/Permissions/IsLoggedIn.php b/src/WpRest/Permissions/IsLoggedIn.php
deleted file mode 100755
index 43c34274..00000000
--- a/src/WpRest/Permissions/IsLoggedIn.php
+++ /dev/null
@@ -1,29 +0,0 @@
- 403]
- );
- }
-}
diff --git a/tests/Benchmark/BenchmarkDiscovery.php b/tests/Benchmark/BenchmarkDiscovery.php
deleted file mode 100644
index ab42faac..00000000
--- a/tests/Benchmark/BenchmarkDiscovery.php
+++ /dev/null
@@ -1,120 +0,0 @@
-items = new DiscoveryItems;
- }
-
- public function discover(
- DiscoveryLocationInterface $location,
- DiscoveredStructure $structure,
- ?ReflectionCacheInterface $reflectionCache = null
- ): void {
- if (! $structure instanceof DiscoveredClass) {
- return;
- }
-
- if ($structure->isAbstract) {
- return;
- }
-
- $className = $structure->namespace.'\\'.$structure->name;
-
- if (! $reflectionCache instanceof ReflectionCacheInterface) {
- return;
- }
-
- try {
- $reflection = $reflectionCache->getClassReflection($className);
-
- // Process ALL class-level attributes (like PostTypeDiscovery does)
- $classAttributes = $reflection->getAttributes();
- foreach ($classAttributes as $attribute) {
- try {
- $instance = $attribute->newInstance();
- $this->items->add($location, [
- 'type' => 'class_attribute',
- 'class' => $className,
- 'attribute' => $attribute->getName(),
- 'instance' => $instance,
- ]);
- } catch (\Throwable) {
- // Skip attributes that can't be instantiated (missing classes)
- }
- }
-
- // Process ALL public methods and their attributes (like HookDiscovery does)
- $methods = $reflection->getMethods(ReflectionMethod::IS_PUBLIC);
- foreach ($methods as $method) {
- $methodAttributes = $method->getAttributes();
- foreach ($methodAttributes as $attribute) {
- try {
- $instance = $attribute->newInstance();
- $this->items->add($location, [
- 'type' => 'method_attribute',
- 'class' => $className,
- 'method' => $method->getName(),
- 'attribute' => $attribute->getName(),
- 'instance' => $instance,
- 'param_count' => $method->getNumberOfParameters(),
- ]);
- } catch (\Throwable) {
- // Skip attributes that can't be instantiated
- }
- }
- }
- } catch (\Throwable) {
- // Skip unloadable classes, like real discoveries
- }
- }
-
- public function getItems(): DiscoveryItemsInterface
- {
- return $this->items;
- }
-
- public function setItems(DiscoveryItemsInterface $items): void
- {
- $this->items = $items;
- }
-
- public function apply(): void
- {
- // No-op for benchmarking — we measure discovery, not application
- }
-
- public function getIdentifier(): string
- {
- return $this->identifier;
- }
-}
diff --git a/tests/Benchmark/DiscoveryBenchmark.php b/tests/Benchmark/DiscoveryBenchmark.php
deleted file mode 100644
index 603c701f..00000000
--- a/tests/Benchmark/DiscoveryBenchmark.php
+++ /dev/null
@@ -1,479 +0,0 @@
-> */
- private array $results = [];
-
- /** @var array> */
- private array $resultsMulti = [];
-
- public function __construct()
- {
- $this->container = new Container;
- Container::setInstance($this->container);
-
- $this->debugDetector = new class implements DebugDetectorInterface
- {
- public function isDebugMode(): bool
- {
- return true; // Force no persistent cache
- }
- };
-
- $this->fixturesBasePath = __DIR__.'/Generated';
- }
-
- /**
- * Run the full benchmark suite.
- *
- * @param int[] $classCounts Class counts to benchmark
- * @param int $iterations Number of iterations per benchmark for averaging
- */
- public function run(array $classCounts = [50, 100, 250, 500, 1000, 2000], int $iterations = 3): void
- {
- $this->printHeader();
-
- echo "\n ── SINGLE LOCATION (flat directory) ──\n";
- foreach ($classCounts as $count) {
- $this->benchmarkClassCount($count, $iterations);
- }
-
- $this->printSummary();
-
- // Multi-location benchmark
- echo "\n ── MULTI-LOCATION (modules with DDD subdirs) ──\n";
- $this->resultsMulti = [];
- $multiScenarios = [
- ['classes' => 100, 'modules' => 5],
- ['classes' => 250, 'modules' => 8],
- ['classes' => 500, 'modules' => 10],
- ['classes' => 1000, 'modules' => 15],
- ];
-
- foreach ($multiScenarios as $scenario) {
- $this->benchmarkMultiLocation($scenario['classes'], $scenario['modules'], $iterations);
- }
-
- $this->printMultiLocationSummary();
- }
-
- private function benchmarkClassCount(int $count, int $iterations): void
- {
- echo "\n".str_repeat('─', 70)."\n";
- echo " Benchmarking with {$count} classes ({$iterations} iterations)\n";
- echo str_repeat('─', 70)."\n";
-
- // Generate fixtures
- $generator = new FixtureGenerator(
- $this->fixturesBasePath,
- 'Tests\\Benchmark\\Generated'
- );
-
- $fixtureInfo = $generator->generate($count);
- $this->printDistribution($fixtureInfo['distribution']);
-
- // Register autoloader for generated classes
- $autoloader = $this->registerAutoloader($fixtureInfo['path'], $fixtureInfo['namespace']);
-
- $timings = [
- 'spatie_scan' => [],
- 'full_discovery' => [],
- 'memory_peak' => [],
- 'classes_processed' => [],
- 'total_items' => [],
- ];
-
- for ($i = 0; $i < $iterations; $i++) {
- echo ' Iteration '.($i + 1).'...';
-
- // Clear any static caches between iterations
- $this->clearStaticCaches();
-
- // Benchmark: Spatie scan phase only
- $scanResult = $this->benchmarkSpatieScan($fixtureInfo);
- $timings['spatie_scan'][] = $scanResult['time_ms'];
-
- // Clear static caches again
- $this->clearStaticCaches();
-
- // Benchmark: Full discovery (scan + process for all discovery types)
- $fullResult = $this->benchmarkFullDiscovery($fixtureInfo);
- $timings['full_discovery'][] = $fullResult['time_ms'];
- $timings['memory_peak'][] = $fullResult['memory_peak_mb'];
- $timings['classes_processed'][] = $fullResult['classes_processed'];
- $timings['total_items'][] = $fullResult['total_items'];
-
- echo " done\n";
- }
-
- // Unregister autoloader
- spl_autoload_unregister($autoloader);
-
- // Cleanup generated files
- $generator->cleanup();
-
- // Store averaged results
- $this->results[$count] = [
- 'count' => $count,
- 'spatie_scan_avg_ms' => $this->average($timings['spatie_scan']),
- 'spatie_scan_min_ms' => min($timings['spatie_scan']),
- 'spatie_scan_max_ms' => max($timings['spatie_scan']),
- 'full_discovery_avg_ms' => $this->average($timings['full_discovery']),
- 'full_discovery_min_ms' => min($timings['full_discovery']),
- 'full_discovery_max_ms' => max($timings['full_discovery']),
- 'memory_peak_mb' => $this->average($timings['memory_peak']),
- 'classes_processed' => (int) $this->average($timings['classes_processed']),
- 'total_items' => (int) $this->average($timings['total_items']),
- 'per_class_us' => ($this->average($timings['full_discovery']) * 1000) / $count,
- ];
-
- $r = $this->results[$count];
- echo "\n";
- echo sprintf(" Spatie scan: avg %7.2f ms (min %.2f / max %.2f)\n", $r['spatie_scan_avg_ms'], $r['spatie_scan_min_ms'], $r['spatie_scan_max_ms']);
- echo sprintf(" Full discovery: avg %7.2f ms (min %.2f / max %.2f)\n", $r['full_discovery_avg_ms'], $r['full_discovery_min_ms'], $r['full_discovery_max_ms']);
- echo sprintf(" Per class: %7.1f us\n", $r['per_class_us']);
- echo sprintf(" Memory peak: %7.2f MB\n", $r['memory_peak_mb']);
- echo sprintf(" Classes found: %d\n", $r['classes_processed']);
- echo sprintf(" Attributes found: %d (avg %.1f/class)\n", $r['total_items'], $r['total_items'] / max(1, $r['classes_processed']));
- }
-
- /**
- * Benchmark: Spatie structure scan only (no discovery processing).
- *
- * @return array{time_ms: float, structures_count: int}
- */
- private function benchmarkSpatieScan(array $fixtureInfo): array
- {
- $cacheManager = new DiscoveryCacheManager($this->container, $this->debugDetector);
- $reflectionCache = new ReflectionCache($this->container);
- $context = new DiscoveryContext($reflectionCache);
-
- $location = new DiscoveryLocation(
- $fixtureInfo['namespace'],
- $fixtureInfo['path']
- );
-
- $start = hrtime(true);
- $structures = $cacheManager->getStructuresForLocation($location, $context);
- $elapsed = (hrtime(true) - $start) / 1_000_000;
-
- return [
- 'time_ms' => $elapsed,
- 'structures_count' => count($structures),
- ];
- }
-
- /**
- * Benchmark: Full discovery with multiple discovery types.
- *
- * Uses a lightweight HookDiscovery mock to test the real DiscoveryEngine
- * processing pipeline without depending on WordPress runtime.
- *
- * @return array{time_ms: float, memory_peak_mb: float, classes_processed: int, stats: array}
- */
- private function benchmarkFullDiscovery(array $fixtureInfo): array
- {
- $engine = new DiscoveryEngine(
- $this->container,
- $this->debugDetector
- );
-
- $location = new DiscoveryLocation(
- $fixtureInfo['namespace'],
- $fixtureInfo['path']
- );
-
- // Add multiple discovery types to simulate realistic load
- $engine->addLocation($location);
- $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks'));
- $engine->addDiscovery('providers', new BenchmarkDiscovery('providers'));
- $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types'));
- $engine->addDiscovery('rest_routes', new BenchmarkDiscovery('rest_routes'));
- $engine->addDiscovery('schedules', new BenchmarkDiscovery('schedules'));
-
- $memBefore = memory_get_usage(true);
- $start = hrtime(true);
-
- $engine->discover();
-
- $elapsed = (hrtime(true) - $start) / 1_000_000;
- $memAfter = memory_get_peak_usage(true);
-
- $stats = $engine->getPerformanceStats();
-
- // Count total discovered items across all discoveries
- $totalItems = 0;
- foreach ($engine->getDiscoveries() as $discovery) {
- $totalItems += count($discovery->getItems());
- }
-
- return [
- 'time_ms' => $elapsed,
- 'memory_peak_mb' => ($memAfter - $memBefore) / (1024 * 1024),
- 'classes_processed' => $stats['context']['total_classes'],
- 'total_items' => $totalItems,
- 'stats' => $stats,
- ];
- }
-
- private function benchmarkMultiLocation(int $totalClasses, int $moduleCount, int $iterations): void
- {
- $key = sprintf('%dc_%dm', $totalClasses, $moduleCount);
- echo "\n".str_repeat('─', 70)."\n";
- echo " {$totalClasses} classes across {$moduleCount} modules ({$iterations} iterations)\n";
- echo str_repeat('─', 70)."\n";
-
- $generator = new FixtureGenerator(
- $this->fixturesBasePath,
- 'Tests\\Benchmark\\Generated'
- );
-
- $multiInfo = $generator->generateMultiLocation($totalClasses, $moduleCount);
- echo sprintf(" Generated: %d classes, %d modules, %d subdirs\n",
- $multiInfo['total_classes'], $multiInfo['modules'], $multiInfo['dirs_created']);
-
- // Register autoloaders for all locations
- $autoloaders = [];
- foreach ($multiInfo['locations'] as $loc) {
- $autoloaders[] = $this->registerAutoloader($loc['path'], $loc['namespace']);
- }
-
- $timings = ['scan' => [], 'discovery' => [], 'memory' => [], 'items' => []];
-
- for ($i = 0; $i < $iterations; $i++) {
- echo ' Iteration '.($i + 1).'...';
- $this->clearStaticCaches();
-
- // Full discovery with multiple locations
- $engine = new DiscoveryEngine($this->container, $this->debugDetector);
-
- foreach ($multiInfo['locations'] as $loc) {
- $engine->addLocation(new DiscoveryLocation($loc['namespace'], $loc['path']));
- }
-
- $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks'));
- $engine->addDiscovery('providers', new BenchmarkDiscovery('providers'));
- $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types'));
- $engine->addDiscovery('rest_routes', new BenchmarkDiscovery('rest_routes'));
- $engine->addDiscovery('schedules', new BenchmarkDiscovery('schedules'));
-
- $memBefore = memory_get_usage(true);
- $start = hrtime(true);
- $engine->discover();
- $elapsed = (hrtime(true) - $start) / 1_000_000;
-
- $totalItems = 0;
- foreach ($engine->getDiscoveries() as $d) {
- $totalItems += count($d->getItems());
- }
-
- $timings['scan'][] = $elapsed; // Combined scan + processing
- $timings['memory'][] = (memory_get_peak_usage(true) - $memBefore) / (1024 * 1024);
- $timings['items'][] = $totalItems;
-
- echo " done\n";
- }
-
- foreach ($autoloaders as $al) {
- spl_autoload_unregister($al);
- }
-
- $generator->cleanup();
-
- $avgMs = $this->average($timings['scan']);
- $this->resultsMulti[$key] = [
- 'classes' => $multiInfo['total_classes'],
- 'modules' => $multiInfo['modules'],
- 'dirs' => $multiInfo['dirs_created'],
- 'avg_ms' => $avgMs,
- 'min_ms' => min($timings['scan']),
- 'max_ms' => max($timings['scan']),
- 'per_class_us' => ($avgMs * 1000) / max(1, $multiInfo['total_classes']),
- 'items' => (int) $this->average($timings['items']),
- 'memory_mb' => $this->average($timings['memory']),
- ];
-
- $r = $this->resultsMulti[$key];
- echo sprintf("\n Discovery: avg %7.2f ms (min %.2f / max %.2f)\n", $r['avg_ms'], $r['min_ms'], $r['max_ms']);
- echo sprintf(" Per class: %7.1f us\n", $r['per_class_us']);
- echo sprintf(" Attrs found: %d\n", $r['items']);
- echo sprintf(" Memory peak: %7.2f MB\n", $r['memory_mb']);
- }
-
- private function printMultiLocationSummary(): void
- {
- if ($this->resultsMulti === []) {
- return;
- }
-
- echo "\n\n";
- echo "╔════════════════════════════════════════════════════════════════════════════╗\n";
- echo "║ MULTI-LOCATION SUMMARY ║\n";
- echo "╠════════════════════════════════════════════════════════════════════════════╣\n";
- echo sprintf("║ %-7s │ %-7s │ %-5s │ %-11s │ %-9s │ %-6s │ %-6s ║\n",
- 'Classes', 'Modules', 'Dirs', 'Discov (ms)', 'Per cls', 'Attrs', 'Mem MB');
- echo "╠════════════════════════════════════════════════════════════════════════════╣\n";
-
- foreach ($this->resultsMulti as $r) {
- echo sprintf(
- "║ %-7d │ %-7d │ %-5d │ %11.2f │ %6.1f us │ %6d │ %5.2f ║\n",
- $r['classes'], $r['modules'], $r['dirs'],
- $r['avg_ms'], $r['per_class_us'], $r['items'], $r['memory_mb']
- );
- }
-
- echo "╚════════════════════════════════════════════════════════════════════════════╝\n";
-
- // Compare single vs multi for same class count
- echo "\n Single-location vs Multi-location comparison:\n";
- foreach ($this->resultsMulti as $r) {
- $classCount = $r['classes'];
- // Find closest single-location result
- $closest = null;
- $closestDiff = PHP_INT_MAX;
- foreach ($this->results as $sr) {
- $diff = abs($sr['count'] - $classCount);
- if ($diff < $closestDiff) {
- $closestDiff = $diff;
- $closest = $sr;
- }
- }
-
- if ($closest && $closestDiff < $classCount * 0.3) {
- $overhead = (($r['avg_ms'] / max(0.01, $closest['full_discovery_avg_ms'])) - 1) * 100;
- echo sprintf(" %d classes: single=%.1fms, multi(%d modules)=%.1fms => %+.0f%% overhead\n",
- $classCount, $closest['full_discovery_avg_ms'], $r['modules'], $r['avg_ms'], $overhead);
- }
- }
-
- echo "\n";
- }
-
- private function registerAutoloader(string $path, string $namespace): \Closure
- {
- $autoloader = function (string $class) use ($path, $namespace): void {
- if (! str_starts_with($class, $namespace)) {
- return;
- }
-
- $relative = str_replace($namespace.'\\', '', $class);
- $file = $path.'/'.str_replace('\\', '/', $relative).'.php';
-
- if (file_exists($file)) {
- require_once $file;
- }
- };
-
- spl_autoload_register($autoloader);
-
- return $autoloader;
- }
-
- private function clearStaticCaches(): void
- {
- // Use reflection to clear DiscoveryCacheManager's static cache
- $ref = new \ReflectionClass(DiscoveryCacheManager::class);
- $prop = $ref->getProperty('structuresCache');
- $prop->setValue(null, []);
- }
-
- private function average(array $values): float
- {
- return array_sum($values) / count($values);
- }
-
- private function printHeader(): void
- {
- echo "\n";
- echo "╔══════════════════════════════════════════════════════════════════════╗\n";
- echo "║ Pollora Discovery System — Performance Benchmark ║\n";
- echo "╠══════════════════════════════════════════════════════════════════════╣\n";
- echo "║ Tests discovery WITHOUT persistent cache (debug mode). ║\n";
- echo "║ Measures: Spatie scan, full discovery pipeline, per-class cost. ║\n";
- echo "╚══════════════════════════════════════════════════════════════════════╝\n";
- echo "\n";
- echo ' PHP '.phpversion().' | Memory limit: '.ini_get('memory_limit')."\n";
- }
-
- private function printDistribution(array $distribution): void
- {
- $parts = [];
- foreach ($distribution as $type => $count) {
- $parts[] = sprintf('%s: %s', $type, $count);
- }
-
- echo ' Distribution: '.implode(', ', $parts)."\n";
- }
-
- private function printSummary(): void
- {
- echo "\n\n";
- echo "╔══════════════════════════════════════════════════════════════════════╗\n";
- echo "║ SUMMARY TABLE ║\n";
- echo "╠══════════════════════════════════════════════════════════════════════╣\n";
- echo sprintf("║ %-7s │ %-10s │ %-12s │ %-9s │ %-6s │ %-6s ║\n", 'Classes', 'Scan (ms)', 'Discov (ms)', 'Per cls', 'Attrs', 'Mem MB');
- echo "╠══════════════════════════════════════════════════════════════════════╣\n";
-
- foreach ($this->results as $r) {
- echo sprintf(
- "║ %-7d │ %10.2f │ %12.2f │ %6.1f us │ %6d │ %5.2f ║\n",
- $r['count'],
- $r['spatie_scan_avg_ms'],
- $r['full_discovery_avg_ms'],
- $r['per_class_us'],
- $r['total_items'],
- $r['memory_peak_mb']
- );
- }
-
- echo "╚══════════════════════════════════════════════════════════════════════╝\n";
-
- // Scaling analysis
- if (count($this->results) >= 2) {
- echo "\n Scaling analysis:\n";
- $keys = array_keys($this->results);
- $first = $this->results[$keys[0]];
- $last = $this->results[$keys[count($keys) - 1]];
- $classRatio = $last['count'] / $first['count'];
- $timeRatio = $last['full_discovery_avg_ms'] / max(0.01, $first['full_discovery_avg_ms']);
-
- echo sprintf(" %dx classes => %.1fx time (ideal linear = %.1fx)\n", (int) $classRatio, $timeRatio, $classRatio);
-
- if ($timeRatio > $classRatio * 1.5) {
- echo " => Super-linear scaling detected — optimization opportunities likely\n";
- } elseif ($timeRatio <= $classRatio * 1.1) {
- echo " => Linear scaling — discovery scales well\n";
- } else {
- echo " => Near-linear scaling — acceptable\n";
- }
- }
-
- echo "\n";
- }
-}
diff --git a/tests/Benchmark/DiscoveryPerformanceTest.php b/tests/Benchmark/DiscoveryPerformanceTest.php
deleted file mode 100644
index 9eb86ff2..00000000
--- a/tests/Benchmark/DiscoveryPerformanceTest.php
+++ /dev/null
@@ -1,331 +0,0 @@
-getProperty('structuresCache');
- $prop->setValue(null, []);
-}
-
-function generateAndRegister(int $count): array
-{
- $basePath = __DIR__.'/Generated';
- $namespace = 'Tests\\Benchmark\\Generated';
-
- $generator = new FixtureGenerator($basePath, $namespace);
- $info = $generator->generate($count);
-
- $autoloader = function (string $class) use ($basePath, $namespace): void {
- if (! str_starts_with($class, $namespace)) {
- return;
- }
-
- $relative = str_replace($namespace.'\\', '', $class);
- $file = $basePath.'/'.str_replace('\\', '/', $relative).'.php';
- if (file_exists($file)) {
- require_once $file;
- }
- };
- spl_autoload_register($autoloader);
-
- return ['info' => $info, 'generator' => $generator, 'autoloader' => $autoloader];
-}
-
-describe('Discovery Performance', function (): void {
-
- afterEach(function (): void {
- clearDiscoveryCacheManagerStatic();
- restorePreviousContainer();
- Facade::clearResolvedInstances();
- Facade::setFacadeApplication(Container::getInstance());
- });
-
- it('scans 100 classes via Spatie in under 500ms', function (): void {
- $setup = generateAndRegister(100);
-
- try {
- $container = createBenchContainer();
- $cacheManager = new DiscoveryCacheManager($container, createBenchDebugDetector());
- $reflectionCache = new ReflectionCache($container);
- $context = new DiscoveryContext($reflectionCache);
-
- $location = new DiscoveryLocation(
- $setup['info']['namespace'],
- $setup['info']['path']
- );
-
- $start = hrtime(true);
- $structures = $cacheManager->getStructuresForLocation($location, $context);
- $elapsedMs = (hrtime(true) - $start) / 1_000_000;
-
- expect(count($structures))->toBeGreaterThanOrEqual(100);
- expect($elapsedMs)->toBeLessThan(500);
- } finally {
- spl_autoload_unregister($setup['autoloader']);
- $setup['generator']->cleanup();
- }
- });
-
- it('runs full discovery on 100 classes with 5 discoveries in under 2000ms', function (): void {
- $setup = generateAndRegister(100);
-
- try {
- $container = createBenchContainer();
- $engine = new DiscoveryEngine($container, createBenchDebugDetector());
-
- $location = new DiscoveryLocation(
- $setup['info']['namespace'],
- $setup['info']['path']
- );
-
- $engine->addLocation($location);
- $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks'));
- $engine->addDiscovery('providers', new BenchmarkDiscovery('providers'));
- $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types'));
- $engine->addDiscovery('rest_routes', new BenchmarkDiscovery('rest_routes'));
- $engine->addDiscovery('schedules', new BenchmarkDiscovery('schedules'));
-
- $start = hrtime(true);
- $engine->discover();
- $elapsedMs = (hrtime(true) - $start) / 1_000_000;
-
- $stats = $engine->getPerformanceStats();
-
- expect($stats['context']['total_classes'])->toBeGreaterThanOrEqual(100);
- expect($elapsedMs)->toBeLessThan(2000);
- } finally {
- spl_autoload_unregister($setup['autoloader']);
- $setup['generator']->cleanup();
- }
- });
-
- it('runs full discovery on 500 classes with 5 discoveries in under 10000ms', function (): void {
- $setup = generateAndRegister(500);
-
- try {
- $container = createBenchContainer();
- $engine = new DiscoveryEngine($container, createBenchDebugDetector());
-
- $location = new DiscoveryLocation(
- $setup['info']['namespace'],
- $setup['info']['path']
- );
-
- $engine->addLocation($location);
- $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks'));
- $engine->addDiscovery('providers', new BenchmarkDiscovery('providers'));
- $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types'));
- $engine->addDiscovery('rest_routes', new BenchmarkDiscovery('rest_routes'));
- $engine->addDiscovery('schedules', new BenchmarkDiscovery('schedules'));
-
- $start = hrtime(true);
- $engine->discover();
- $elapsedMs = (hrtime(true) - $start) / 1_000_000;
-
- $stats = $engine->getPerformanceStats();
-
- expect($stats['context']['total_classes'])->toBeGreaterThanOrEqual(500);
- expect($elapsedMs)->toBeLessThan(10000);
- } finally {
- spl_autoload_unregister($setup['autoloader']);
- $setup['generator']->cleanup();
- }
- });
-
- it('scales near-linearly from 100 to 500 classes', function (): void {
- // Benchmark 100 classes
- $setup100 = generateAndRegister(100);
- $container = createBenchContainer();
-
- try {
- $engine100 = new DiscoveryEngine($container, createBenchDebugDetector());
- $loc100 = new DiscoveryLocation($setup100['info']['namespace'], $setup100['info']['path']);
- $engine100->addLocation($loc100);
- $engine100->addDiscovery('hooks', new BenchmarkDiscovery('hooks'));
- $engine100->addDiscovery('providers', new BenchmarkDiscovery('providers'));
- $engine100->addDiscovery('post_types', new BenchmarkDiscovery('post_types'));
-
- $start = hrtime(true);
- $engine100->discover();
- $time100 = (hrtime(true) - $start) / 1_000_000;
- } finally {
- spl_autoload_unregister($setup100['autoloader']);
- $setup100['generator']->cleanup();
- }
-
- clearDiscoveryCacheManagerStatic();
-
- // Benchmark 500 classes
- $setup500 = generateAndRegister(500);
-
- try {
- $engine500 = new DiscoveryEngine($container, createBenchDebugDetector());
- $loc500 = new DiscoveryLocation($setup500['info']['namespace'], $setup500['info']['path']);
- $engine500->addLocation($loc500);
- $engine500->addDiscovery('hooks', new BenchmarkDiscovery('hooks'));
- $engine500->addDiscovery('providers', new BenchmarkDiscovery('providers'));
- $engine500->addDiscovery('post_types', new BenchmarkDiscovery('post_types'));
-
- $start = hrtime(true);
- $engine500->discover();
- $time500 = (hrtime(true) - $start) / 1_000_000;
- } finally {
- spl_autoload_unregister($setup500['autoloader']);
- $setup500['generator']->cleanup();
- }
-
- // 5x classes should be less than 8x time (allowing for overhead)
- $ratio = $time500 / max(0.01, $time100);
- expect($ratio)->toBeLessThan(8.0,
- sprintf('Scaling ratio: %sx for 5x classes (100: %sms, 500: %sms)', $ratio, $time100, $time500)
- );
- });
-
- it('processes each class in under 2000 microseconds on average', function (): void {
- $setup = generateAndRegister(250);
-
- try {
- $container = createBenchContainer();
- $engine = new DiscoveryEngine($container, createBenchDebugDetector());
-
- $location = new DiscoveryLocation(
- $setup['info']['namespace'],
- $setup['info']['path']
- );
-
- $engine->addLocation($location);
- $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks'));
- $engine->addDiscovery('providers', new BenchmarkDiscovery('providers'));
- $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types'));
- $engine->addDiscovery('rest_routes', new BenchmarkDiscovery('rest_routes'));
- $engine->addDiscovery('schedules', new BenchmarkDiscovery('schedules'));
-
- $start = hrtime(true);
- $engine->discover();
- $elapsedUs = (hrtime(true) - $start) / 1_000;
-
- $perClassUs = $elapsedUs / 250;
-
- expect($perClassUs)->toBeLessThan(3000,
- sprintf('Per-class cost: %s us (total: ', $perClassUs).($elapsedUs / 1000).' ms for 250 classes)'
- );
- } finally {
- spl_autoload_unregister($setup['autoloader']);
- $setup['generator']->cleanup();
- }
- });
-
- it('handles multi-location with DDD subdirectories without excessive overhead', function (): void {
- $basePath = __DIR__.'/Generated';
- $namespace = 'Tests\\Benchmark\\Generated';
- $generator = new FixtureGenerator($basePath, $namespace);
-
- $multiInfo = $generator->generateMultiLocation(200, 5);
-
- $autoloaders = [];
- foreach ($multiInfo['locations'] as $loc) {
- $al = function (string $class) use ($loc): void {
- if (! str_starts_with($class, $loc['namespace'])) {
- return;
- }
-
- $relative = str_replace($loc['namespace'].'\\', '', $class);
- $file = $loc['path'].'/'.str_replace('\\', '/', $relative).'.php';
- if (file_exists($file)) {
- require_once $file;
- }
- };
- spl_autoload_register($al);
- $autoloaders[] = $al;
- }
-
- try {
- $container = createBenchContainer();
- $engine = new DiscoveryEngine($container, createBenchDebugDetector());
-
- foreach ($multiInfo['locations'] as $loc) {
- $engine->addLocation(new DiscoveryLocation($loc['namespace'], $loc['path']));
- }
-
- $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks'));
- $engine->addDiscovery('providers', new BenchmarkDiscovery('providers'));
- $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types'));
-
- $start = hrtime(true);
- $engine->discover();
- $elapsedMs = (hrtime(true) - $start) / 1_000_000;
-
- $stats = $engine->getPerformanceStats();
-
- expect($stats['context']['total_classes'])->toBeGreaterThanOrEqual(200);
- // 5 modules with subdirs should not be dramatically slower than flat
- expect($elapsedMs)->toBeLessThan(5000);
- } finally {
- foreach ($autoloaders as $al) {
- spl_autoload_unregister($al);
- }
-
- $generator->cleanup();
- }
- });
-});
diff --git a/tests/Benchmark/Fixtures/FixtureGenerator.php b/tests/Benchmark/Fixtures/FixtureGenerator.php
deleted file mode 100644
index c35e6758..00000000
--- a/tests/Benchmark/Fixtures/FixtureGenerator.php
+++ /dev/null
@@ -1,673 +0,0 @@
-}
- */
- public function generate(int $count): array
- {
- $this->ensureDirectory($this->basePath);
-
- $distribution = $this->calculateDistribution($count);
- $index = 0;
-
- foreach ($distribution as $type => $typeCount) {
- for ($i = 0; $i < $typeCount; $i++) {
- $this->generateClass($type, $index);
- $index++;
- }
- }
-
- return [
- 'path' => $this->basePath,
- 'namespace' => $this->namespace,
- 'count' => $index,
- 'distribution' => $distribution,
- ];
- }
-
- /**
- * Generate a multi-location structure simulating a real Pollora project.
- *
- * Creates N modules, each with a DDD-like directory structure:
- * ModuleName/
- * app/
- * PostTypes/
- * Hooks/
- * Http/Controllers/
- * Providers/
- * Jobs/
- * Models/
- *
- * Each module is a separate discovery location (like nwidart modules).
- *
- * @param int $totalClasses Total number of classes across all modules
- * @param int $moduleCount Number of modules to create
- * @return array{locations: array, total_classes: int, modules: int, dirs_created: int}
- */
- public function generateMultiLocation(int $totalClasses, int $moduleCount): array
- {
- $this->ensureDirectory($this->basePath);
-
- $classesPerModule = (int) ceil($totalClasses / $moduleCount);
- $locations = [];
- $totalGenerated = 0;
- $dirsCreated = 0;
- $remaining = $totalClasses;
-
- for ($mod = 0; $mod < $moduleCount; $mod++) {
- $moduleName = 'Module'.chr(65 + ($mod % 26)).($mod >= 26 ? $mod : '');
- $modulePath = $this->basePath.'/'.$moduleName.'/app';
- $moduleNamespace = $this->namespace.'\\'.$moduleName.'\\App';
- $classCount = min($classesPerModule, $remaining);
-
- if ($classCount <= 0) {
- break;
- }
-
- // DDD subdirectories
- $subdirs = [
- 'PostTypes' => 'post_type',
- 'Hooks' => 'hook',
- 'Http/Controllers' => 'rest',
- 'Providers' => 'provider',
- 'Jobs' => 'schedule',
- 'Models' => 'plain',
- 'Services' => 'mixed',
- ];
-
- $perSubdir = (int) ceil($classCount / count($subdirs));
- $moduleGenerated = 0;
-
- foreach ($subdirs as $subdir => $type) {
- $subdirPath = $modulePath.'/'.$subdir;
- $subdirNamespace = $moduleNamespace.'\\'.str_replace('/', '\\', $subdir);
-
- $subdirCount = min($perSubdir, $classCount - $moduleGenerated);
- if ($subdirCount <= 0) {
- break;
- }
-
- $this->ensureDirectory($subdirPath);
- $dirsCreated++;
-
- for ($i = 0; $i < $subdirCount; $i++) {
- $globalIndex = $totalGenerated + $moduleGenerated + $i;
- $className = $this->classNameForType($type, $globalIndex);
- $content = $this->generateClassContent($type, $className, $globalIndex, $subdirNamespace);
- file_put_contents($subdirPath.'/'.$className.'.php', $content);
- }
-
- $moduleGenerated += $subdirCount;
- }
-
- $locations[] = [
- 'path' => $modulePath,
- 'namespace' => $moduleNamespace,
- ];
-
- $totalGenerated += $moduleGenerated;
- $remaining -= $moduleGenerated;
- }
-
- return [
- 'locations' => $locations,
- 'total_classes' => $totalGenerated,
- 'modules' => count($locations),
- 'dirs_created' => $dirsCreated,
- ];
- }
-
- /**
- * Clean up generated fixtures.
- */
- public function cleanup(): void
- {
- if (is_dir($this->basePath)) {
- $this->removeDirectory($this->basePath);
- }
- }
-
- private function calculateDistribution(int $count): array
- {
- $hooks = (int) round($count * 0.30);
- $providers = (int) round($count * 0.15);
- $postTypes = (int) round($count * 0.15);
- $rest = (int) round($count * 0.10);
- $schedule = (int) round($count * 0.10);
- $mixed = (int) round($count * 0.10);
- $plain = $count - $hooks - $providers - $postTypes - $rest - $schedule - $mixed;
-
- return [
- 'hook' => $hooks,
- 'provider' => $providers,
- 'post_type' => $postTypes,
- 'rest' => $rest,
- 'schedule' => $schedule,
- 'mixed' => $mixed,
- 'plain' => max(0, $plain),
- ];
- }
-
- private function generateClass(string $type, int $index): void
- {
- $className = $this->classNameForType($type, $index);
- $content = $this->generateClassContent($type, $className, $index, $this->namespace);
-
- file_put_contents($this->basePath.'/'.$className.'.php', $content);
- }
-
- private function generateClassContent(string $type, string $className, int $index, string $namespace): string
- {
- return match ($type) {
- 'hook' => $this->generateHookClass($className, $index, $namespace),
- 'provider' => $this->generateProviderClass($className, $namespace),
- 'post_type' => $this->generatePostTypeClass($className, $index, $namespace),
- 'rest' => $this->generateRestClass($className, $index, $namespace),
- 'schedule' => $this->generateScheduleClass($className, $index, $namespace),
- 'mixed' => $this->generateMixedClass($className, $index, $namespace),
- 'plain' => $this->generatePlainClass($className, $namespace),
- };
- }
-
- private function classNameForType(string $type, int $index): string
- {
- return match ($type) {
- 'hook' => 'BenchHook'.$index,
- 'provider' => 'BenchProvider'.$index,
- 'post_type' => 'BenchPostType'.$index,
- 'rest' => 'BenchRestController'.$index,
- 'schedule' => 'BenchSchedule'.$index,
- 'mixed' => 'BenchMixed'.$index,
- 'plain' => 'BenchPlain'.$index,
- };
- }
-
- /**
- * Hook class: 2-6 methods, each with 1-3 repeatable Action/Filter attributes.
- */
- private function generateHookClass(string $className, int $index, ?string $namespace = null): string
- {
- $ns = $namespace ?? $this->namespace;
- $methodCount = ($index % 5) + 2; // 2 to 6 methods
- $methods = '';
-
- for ($m = 0; $m < $methodCount; $m++) {
- $attrCount = ($m % 3) + 1; // 1 to 3 attributes per method
- $attrs = '';
-
- for ($a = 0; $a < $attrCount; $a++) {
- $hookName = sprintf('bench_hook_%d_%d_%d', $index, $m, $a);
- $attr = ($m + $a) % 3 === 0 ? 'Filter' : 'Action';
- $priority = (($m * 3) + $a) * 5 + 10;
- $attrs .= " #[\\Pollora\\Attributes\\{$attr}(hook: '{$hookName}', priority: {$priority})]\n";
- }
-
- $methods .= <<namespace;
-
- return <<namespace;
- $slug = 'bench_cpt_'.$index;
-
- // Pool of class-level PostType sub-attributes
- $subAttributes = [
- "#[\\Pollora\\Attributes\\PostType\\Supports(features: ['title', 'editor', 'thumbnail', 'excerpt', 'comments'])]",
- "#[\\Pollora\\Attributes\\PostType\\MenuIcon(value: 'dashicons-admin-post')]",
- '#[\\Pollora\\Attributes\\PostType\\HasArchive(value: true)]',
- '#[\\Pollora\\Attributes\\PostType\\ShowInRest(value: true)]',
- '#[\\Pollora\\Attributes\\PostType\\ShowUI(value: true)]',
- '#[\\Pollora\\Attributes\\PostType\\Hierarchical(value: false)]',
- '#[\\Pollora\\Attributes\\PostType\\PublicPostType(value: true)]',
- '#[\\Pollora\\Attributes\\PostType\\MenuPosition(value: 25)]',
- '#[\\Pollora\\Attributes\\PostType\\ExcludeFromSearch(value: false)]',
- '#[\\Pollora\\Attributes\\PostType\\PubliclyQueryable(value: true)]',
- '#[\\Pollora\\Attributes\\PostType\\CanExport(value: true)]',
- '#[\\Pollora\\Attributes\\PostType\\DeleteWithUser(value: false)]',
- ];
-
- // Pick 3-8 sub-attributes based on index
- $subAttrCount = ($index % 6) + 3;
- $selectedAttrs = array_slice($subAttributes, 0, $subAttrCount);
- $attrBlock = implode("\n", $selectedAttrs);
-
- return <<namespace;
- $methodCount = ($index % 4) + 2; // 2 to 5 methods
- $methods = '';
-
- $httpMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
-
- for ($m = 0; $m < $methodCount; $m++) {
- $httpMethod = $httpMethods[$m % count($httpMethods)];
- $methodName = match ($httpMethod) {
- 'GET' => $m === 0 ? 'index' : 'show',
- 'POST' => 'store',
- 'PUT' => 'update',
- 'DELETE' => 'destroy',
- 'PATCH' => 'patch',
- };
- // Avoid duplicate method names
- $methodName .= $m > 4 ? $m : '';
-
- $methods .= << \$id, 'method' => '{$httpMethod}'];
- }
-
- PHP;
- }
-
- return <<\d+)')]
- class {$className}
- {
- {$methods}
- }
-
- PHP;
- }
-
- /**
- * Schedule class: 1-4 methods each with a Schedule attribute.
- */
- private function generateScheduleClass(string $className, int $index, ?string $namespace = null): string
- {
- $ns = $namespace ?? $this->namespace;
- $methodCount = ($index % 4) + 1; // 1 to 4 methods
- $recurrences = ['hourly', 'daily', 'twicedaily', 'weekly'];
- $methods = '';
-
- for ($m = 0; $m < $methodCount; $m++) {
- $recurrence = $recurrences[$m % count($recurrences)];
- $hookName = sprintf('bench_cron_%d_%d', $index, $m);
- $methods .= <<namespace;
- $slug = 'bench_mixed_'.$index;
- $variant = $index % 3;
-
- return match ($variant) {
- 0 => $this->generatePostTypeWithHooks($className, $index, $slug, $ns),
- 1 => $this->generateRestWithHooksAndSchedule($className, $index, $ns),
- 2 => $this->generateHeavyMixed($className, $index, $slug, $ns),
- };
- }
-
- private function generatePostTypeWithHooks(string $className, int $index, string $slug, ?string $ns = null): string
- {
- $ns ??= $this->namespace;
- $hookCount = ($index % 3) + 2; // 2-4 hook methods
- $methods = '';
-
- for ($m = 0; $m < $hookCount; $m++) {
- $hookName = sprintf('bench_mixed_hook_%d_%d', $index, $m);
- $methods .= <<namespace;
-
- return << \$id];
- }
-
- #[\\Pollora\\Attributes\\Action(hook: 'rest_api_init', priority: 10)]
- public function onRestInit(): void
- {
- // Register additional routes
- }
-
- #[\\Pollora\\Attributes\\Action(hook: 'init', priority: 5)]
- #[\\Pollora\\Attributes\\Action(hook: 'admin_init', priority: 10)]
- public function onInit(): void
- {
- // Multiple hooks on same method
- }
-
- #[\\Pollora\\Attributes\\Schedule(recurrence: 'daily', hook: 'bench_mixed_cron_{$index}')]
- public function cleanup(): void
- {
- // Scheduled cleanup
- }
- }
-
- PHP;
- }
-
- private function generateHeavyMixed(string $className, int $index, string $slug, ?string $ns = null): string
- {
- $ns ??= $this->namespace;
-
- return <<namespace;
-
- return <<isDir()) {
- rmdir($item->getPathname());
- } else {
- unlink($item->getPathname());
- }
- }
-
- rmdir($path);
- }
-}
diff --git a/tests/Benchmark/run.php b/tests/Benchmark/run.php
deleted file mode 100644
index 727cf88d..00000000
--- a/tests/Benchmark/run.php
+++ /dev/null
@@ -1,54 +0,0 @@
-run($classCounts, $iterations);
diff --git a/tests/Feature/Discovery/DiscoveryServiceProviderTest.php b/tests/Feature/Discovery/DiscoveryServiceProviderTest.php
index 50f667c2..13112877 100644
--- a/tests/Feature/Discovery/DiscoveryServiceProviderTest.php
+++ b/tests/Feature/Discovery/DiscoveryServiceProviderTest.php
@@ -5,13 +5,21 @@
use Pollora\Application\Domain\Contracts\DebugDetectorInterface;
use Pollora\Discovery\Application\Services\DiscoveryManager;
use Pollora\Discovery\Domain\Contracts\DiscoveryEngineInterface;
+use Pollora\Discovery\Domain\Contracts\InstanceResolverInterface;
use Pollora\Discovery\Domain\Contracts\ReflectionCacheInterface;
+use Pollora\Discovery\Infrastructure\Adapters\LaravelInstanceResolver;
use Pollora\Discovery\Infrastructure\Providers\DiscoveryServiceProvider;
+use Pollora\Discovery\Infrastructure\Services\DiscoveryCacheManager;
use Pollora\Discovery\Infrastructure\Services\DiscoveryEngine;
use Pollora\Discovery\Infrastructure\Services\InstancePool;
use Pollora\Discovery\Infrastructure\Services\ReflectionCache;
use Pollora\Discovery\Infrastructure\Services\ServiceProviderDiscovery;
+final class InstancePoolTestService
+{
+ public function __construct(public string $source = 'by reflection') {}
+}
+
beforeEach(function (): void {
// DiscoveryServiceProvider depends on DebugDetectorInterface
$this->app->singleton(DebugDetectorInterface::class, fn () => Mockery::mock(DebugDetectorInterface::class, [
@@ -56,6 +64,32 @@
expect($this->app->make(DiscoveryManager::class))->toBe($manager);
});
+ it('builds discovery classes through the application container', function (): void {
+ $this->app->bind(InstancePoolTestService::class, fn (): InstancePoolTestService => new InstancePoolTestService('from the container'));
+
+ expect($this->app->make(InstanceResolverInterface::class))->toBeInstanceOf(LaravelInstanceResolver::class)
+ ->and($this->app->make(InstancePool::class)->getInstance(InstancePoolTestService::class)->source)->toBe('from the container');
+ });
+
+ it('shares one cache manager between the engine and its other readers', function (): void {
+ $engine = $this->app->make(DiscoveryEngineInterface::class);
+
+ expect($engine->getCacheManager())->toBe($this->app->make(DiscoveryCacheManager::class));
+ });
+
+ it('turns the persistent cache off in debug mode', function (): void {
+ $this->app->forgetInstance(DiscoveryCacheManager::class);
+ $this->app->singleton(DebugDetectorInterface::class, fn () => Mockery::mock(DebugDetectorInterface::class, [
+ 'isDebugMode' => true,
+ ]));
+
+ expect($this->app->make(DiscoveryCacheManager::class)->isCacheEnabled())->toBeFalse();
+ });
+
+ it('keeps the persistent cache on outside debug mode', function (): void {
+ expect($this->app->make(DiscoveryCacheManager::class)->isCacheEnabled())->toBeTrue();
+ });
+
it('registers ServiceProviderDiscovery as singleton for auto-registration', function (): void {
expect($this->app->bound(ServiceProviderDiscovery::class))->toBeTrue();
});
diff --git a/tests/Feature/Doctor/InstallChecksTest.php b/tests/Feature/Doctor/InstallChecksTest.php
index 29bfbd27..dd5832b8 100644
--- a/tests/Feature/Doctor/InstallChecksTest.php
+++ b/tests/Feature/Doctor/InstallChecksTest.php
@@ -8,6 +8,7 @@
use Pollora\Discovery\Application\Services\DiscoveryManager;
use Pollora\Discovery\Domain\Contracts\DiscoveryLocationInterface;
use Pollora\Discovery\Domain\Models\DiscoveryLocation;
+use Pollora\Discovery\Infrastructure\Adapters\LaravelDiscoveryCacheManagerFactory;
use Pollora\Discovery\Infrastructure\Services\DiscoveryCacheManager;
use Pollora\Doctor\Domain\Enums\RunContext;
use Pollora\Doctor\Infrastructure\Checks\DiscoveryCacheCheck;
@@ -213,7 +214,7 @@ function cacheManager(bool $debug): DiscoveryCacheManager
$detector = Mockery::mock(DebugDetectorInterface::class);
$detector->shouldReceive('isDebugMode')->andReturn($debug);
- return new DiscoveryCacheManager(app(), $detector);
+ return (new LaravelDiscoveryCacheManagerFactory(app(), $detector))->make();
}
beforeEach(function (): void {
diff --git a/tests/Feature/Hook/Async/AsyncActionsCheckTest.php b/tests/Feature/Hook/Async/AsyncActionsCheckTest.php
index ed96ee04..78d6e27a 100644
--- a/tests/Feature/Hook/Async/AsyncActionsCheckTest.php
+++ b/tests/Feature/Hook/Async/AsyncActionsCheckTest.php
@@ -170,8 +170,7 @@ public function dispatch(AsyncPayload $payload, int $delay = 0): void {}
'null' => [['jobs' => ['driver' => 'null']], 'discards jobs'],
]);
-it('warns when WP-Cron events are overdue, with the fix for what runs WP-Cron', function (bool $onPageLoad, bool $laravelScheduler, string $fix): void {
- config(['wordpress.use_laravel_scheduler' => $laravelScheduler]);
+it('warns when WP-Cron events are overdue, with the fix for what runs WP-Cron', function (bool $onPageLoad, string $fix): void {
($this->register)('save_post');
$this->inspector->cronOnPageLoad = $onPageLoad;
$this->inspector->overdue = ['count' => 3, 'oldest' => 1_800_000_000];
@@ -182,9 +181,8 @@ public function dispatch(AsyncPayload $payload, int $delay = 0): void {}
->and($result->details[0])->toBe('3 WP-Cron event(s) overdue, the oldest since 2027-01-15 08:00 UTC: WP-Cron and Action Scheduler run nothing until wp-cron.php is requested')
->and($result->fix)->toContain($fix);
})->with([
- 'disabled' => [false, false, 'DISABLE_WP_CRON is set'],
- 'on page load' => [true, false, 'too few visits'],
- 'Laravel scheduler' => [false, true, 'run a queue worker'],
+ 'disabled' => [false, 'DISABLE_WP_CRON is set'],
+ 'on page load' => [true, 'too few visits'],
]);
it('leaves overdue WP-Cron events alone when no driver in use relies on WP-Cron', function (): void {
diff --git a/tests/Feature/Hook/Async/AsyncAttributeTest.php b/tests/Feature/Hook/Async/AsyncAttributeTest.php
index 8506b5bb..96b402da 100644
--- a/tests/Feature/Hook/Async/AsyncAttributeTest.php
+++ b/tests/Feature/Hook/Async/AsyncAttributeTest.php
@@ -11,7 +11,6 @@
use Pollora\Hook\Async\Async;
use Pollora\Hook\Async\AsyncPayload;
use Pollora\Hook\Async\QueuedHandler;
-use Pollora\Hook\Domain\Contract\Action as ActionContract;
use Pollora\Hook\Infrastructure\Services\AsyncAttributeRegistrar;
use Pollora\Hook\Infrastructure\Services\HookDiscovery;
use Psr\Log\AbstractLogger;
@@ -149,42 +148,3 @@ function registeredCallback(Action $actions, string $hook): mixed
->and($this->logger->errors)->toContain(sprintf('#[Async] on %s::orphan() is ignored: the method has no #[Action] to make asynchronous.', AsyncAttributeHooks::class));
});
});
-
-describe('Action::handle()', function (): void {
- it('registers through the registrar, asynchronously when #[Async] says so', function (): void {
- $locator = new readonly class($this->actions, $this->logger)
- {
- public function __construct(private ActionContract $actions, private AbstractLogger $logger) {}
-
- public function get(string $id): mixed
- {
- return $id === ActionContract::class ? $this->actions : $this->logger;
- }
- };
- $method = new ReflectionMethod(AsyncAttributeHooks::class, 'notify');
- $attribute = new ActionAttribute('transition_post_status');
-
- $attribute->handle($locator, new AsyncAttributeHooks, $method, $attribute);
-
- expect(registeredCallback($this->actions, 'transition_post_status'))->toBeInstanceOf(QueuedHandler::class);
- });
-
- it('works without a logger in the service locator, and ignores a class context', function (): void {
- $locator = new readonly class($this->actions)
- {
- public function __construct(private ActionContract $actions) {}
-
- public function get(string $id): mixed
- {
- return $id === ActionContract::class ? $this->actions : throw new RuntimeException('Not bound');
- }
- };
- $attribute = new ActionAttribute('save_post');
-
- $attribute->handle($locator, new AsyncAttributeInvalidHooks, new ReflectionMethod(AsyncAttributeInvalidHooks::class, 'typo'), $attribute);
- $attribute->handle($locator, new AsyncAttributeHooks, new ReflectionClass(AsyncAttributeHooks::class), new ActionAttribute('init'));
-
- expect(registeredCallback($this->actions, 'save_post'))->toBeArray()
- ->and($this->actions->callbacks('init'))->toBeNull();
- });
-});
diff --git a/tests/Feature/Hook/Async/AsyncInspectorTest.php b/tests/Feature/Hook/Async/AsyncInspectorTest.php
index 1ed06cca..4195a02e 100644
--- a/tests/Feature/Hook/Async/AsyncInspectorTest.php
+++ b/tests/Feature/Hook/Async/AsyncInspectorTest.php
@@ -93,6 +93,15 @@ public function get_results(string $query, string $output): array
->and($queued)->toBeInstanceOf(QueuedHandler::class);
});
+it("does not count as WP-Cron's the events another driver keeps, which the cron option indexes too", function (): void {
+ $now = time();
+ Functions\when('_get_cron_array')->justReturn([
+ $now - 7200 => ['wp_version_check' => ['k1' => ['schedule' => 'daily', 'args' => []]], 'reports' => ['k2' => ['schedule' => 'daily', 'args' => [], 'driver' => 'action-scheduler']]],
+ ]);
+
+ expect($this->inspector->overdueCronEvents(3600))->toBe(['count' => 1, 'oldest' => $now - 7200]);
+});
+
it('counts the WP-Cron events overdue for longer than the grace period', function (): void {
$now = time();
Functions\when('_get_cron_array')->justReturn([
diff --git a/tests/Feature/Hook/HookDiscoveryTest.php b/tests/Feature/Hook/HookDiscoveryTest.php
new file mode 100644
index 00000000..487f9a15
--- /dev/null
+++ b/tests/Feature/Hook/HookDiscoveryTest.php
@@ -0,0 +1,94 @@
+ */
+ public array $calls = [];
+
+ #[ActionAttribute('init')]
+ public function boot(): void
+ {
+ $this->calls[] = 'boot';
+ }
+
+ #[ActionAttribute('save_post', priority: 99)]
+ #[ActionAttribute('edit_post', priority: 20)]
+ public function syncPost(int $postId, object $post): void
+ {
+ $this->calls[] = sprintf('syncPost(%d, %s)', $postId, $post::class);
+ }
+
+ #[FilterAttribute('the_title')]
+ public function title(string $title): string
+ {
+ return 'modified_'.$title;
+ }
+
+ #[FilterAttribute('the_content', priority: 5)]
+ public function content(string $content): string
+ {
+ return $content;
+ }
+
+ public function notAHook(): void
+ {
+ $this->calls[] = 'notAHook';
+ }
+}
+
+beforeEach(function (): void {
+ $this->actions = new Action;
+ $this->filters = new Filter;
+ $discovery = new HookDiscovery($this->actions, $this->filters);
+ $discovery->discover(new DiscoveryLocation('', __DIR__), DiscoveredClass::fromReflection(new ReflectionClass(DiscoveredHooks::class)), new ReflectionCache);
+ $discovery->apply();
+});
+
+/**
+ * The registration made on a hook: its method, priority and accepted arguments.
+ *
+ * @return array{method: string, priority: int, args: int}
+ */
+function hookRegistration(Action|Filter $hooks, string $hook): array
+{
+ $registration = $hooks->callbacks($hook)[0];
+
+ return ['method' => $registration['callback'][1], 'priority' => $registration['priority'], 'args' => $registration['args']];
+}
+
+it('registers each #[Action] with its priority, 10 by default', function (): void {
+ expect(hookRegistration($this->actions, 'init'))->toBe(['method' => 'boot', 'priority' => 10, 'args' => 0])
+ ->and(hookRegistration($this->actions, 'save_post'))->toBe(['method' => 'syncPost', 'priority' => 99, 'args' => 2])
+ ->and(hookRegistration($this->actions, 'edit_post'))->toBe(['method' => 'syncPost', 'priority' => 20, 'args' => 2]);
+});
+
+it('registers each #[Filter] with its priority, 10 by default', function (): void {
+ expect(hookRegistration($this->filters, 'the_title'))->toBe(['method' => 'title', 'priority' => 10, 'args' => 1])
+ ->and(hookRegistration($this->filters, 'the_content'))->toBe(['method' => 'content', 'priority' => 5, 'args' => 1]);
+});
+
+it('calls the method of an instance of the declaring class', function (): void {
+ $callback = $this->filters->callbacks('the_title')[0]['callback'];
+
+ expect($callback[0])->toBeInstanceOf(DiscoveredHooks::class)
+ ->and($callback('Hello'))->toBe('modified_Hello');
+});
+
+it('registers nothing for a method without hook attribute', function (): void {
+ $registrations = array_merge(...array_values($this->actions->all()), ...array_values($this->filters->all()));
+
+ expect(array_map(fn (array $registration): string => $registration['callback'][1], $registrations))
+ ->toHaveCount(5)
+ ->not->toContain('notAHook');
+});
diff --git a/tests/Feature/Schedule/LaravelDriverTest.php b/tests/Feature/Schedule/LaravelDriverTest.php
new file mode 100644
index 00000000..38d56645
--- /dev/null
+++ b/tests/Feature/Schedule/LaravelDriverTest.php
@@ -0,0 +1,328 @@
+cron = [];
+ $this->fired = [];
+ $this->wpOptions = [];
+
+ Functions\when('_get_cron_array')->alias(fn (): array => $this->cron);
+ Functions\when('_set_cron_array')->alias(function (array $cron): bool {
+ $this->cron = $cron;
+
+ return true;
+ });
+ Functions\when('wp_get_schedules')->justReturn(['hourly' => ['interval' => 3600], 'daily' => ['interval' => 86400]]);
+ Functions\when('add_filter')->justReturn(true);
+ Functions\when('add_action')->justReturn(true);
+ Functions\when('remove_filter')->justReturn(true);
+ Functions\when('do_action_ref_array')->alias(function (string $hook, array $args): void {
+ $this->fired[] = [$hook, $args];
+ });
+ Functions\when('wp_cache_flush_runtime')->justReturn(true);
+ Functions\when('get_current_blog_id')->justReturn(1);
+ Functions\when('is_multisite')->justReturn(false);
+ Functions\when('get_option')->alias(fn (string $name, mixed $default = false): mixed => $this->wpOptions[$name] ?? $default);
+ Functions\when('update_option')->alias(function (string $name, mixed $value): bool {
+ $this->wpOptions[$name] = $value;
+
+ return true;
+ });
+ Functions\when('did_action')->justReturn(1);
+
+ config([
+ 'queue.default' => 'database',
+ 'queue.connections.database' => ['driver' => 'database'],
+ 'queue.connections.sqs' => ['driver' => 'sqs'],
+ 'queue.connections.sync' => ['driver' => 'sync'],
+ 'schedule.laravel' => ['connection' => null, 'queue' => null, 'max_delay' => null, 'late_after' => 600],
+ ]);
+
+ Bus::fake();
+ Functions\when('wp_clear_scheduled_hook')->justReturn(0);
+ Functions\when('wp_unschedule_hook')->justReturn(0);
+
+ foreach (['engine', 'fake', 'driverBeforeFake', 'wanted'] as $property) {
+ (new ReflectionClass(Schedule::class))->setStaticPropertyValue($property, null);
+ }
+
+ app()->forgetInstance(LaravelQueueDriver::class);
+ app()->forgetInstance(LateEventRecovery::class);
+ config(['schedule.driver' => 'laravel', 'schedule.protected_hooks' => []]);
+ app()->register(SchedulerServiceProvider::class, force: true);
+
+ $this->driver = resolve(LaravelQueueDriver::class);
+ $this->engine = Schedule::engine();
+});
+
+/**
+ * @return list
+ */
+function dispatchedJobs(): array
+{
+ return Bus::dispatched(RunScheduledEvent::class)->all();
+}
+
+describe('the driver', function (): void {
+ it('is available on a queue a worker runs, not on sync or null', function (): void {
+ expect($this->driver->available())->toBeTrue();
+
+ config(['schedule.laravel.connection' => 'sync']);
+ expect($this->driver->available())->toBeFalse();
+
+ config(['schedule.laravel.connection' => 'missing']);
+ expect($this->driver->available())->toBeFalse();
+ });
+
+ it('is built before the application has a bus: the driver is chosen while the providers register', function (): void {
+ $driver = new LaravelQueueDriver(new Container, resolve('config'));
+
+ expect($driver->available())->toBeTrue();
+ });
+
+ it('queues a delayed job and writes the entry, with its identifier, in the index', function (): void {
+ $time = time() + 7200;
+ $this->engine->place(new Event('digest', $time, null, ['u' => 1]));
+
+ $job = dispatchedJobs()[0];
+
+ expect(dispatchedJobs())->toHaveCount(1)
+ ->and($job->hook)->toBe('digest')
+ ->and($job->timestamp)->toBe($time)
+ ->and($job->delay)->toBeBetween(7199, 7200)
+ ->and($job->connection)->toBe('database')
+ ->and(CronIndex::ownerOf('digest', ['u' => 1]))->toBe('laravel')
+ ->and(CronIndex::idOf('digest', ['u' => 1]))->toBe($job->id)
+ ->and($this->engine->next('digest', ['u' => 1]))->toBe($time);
+ });
+
+ it('queues a job for an event already due without a delay', function (): void {
+ $this->engine->place(new Event('digest', time() - 5));
+
+ expect(dispatchedJobs()[0]->delay)->toBeNull();
+ });
+
+ it('waits by the longest delay a connection accepts, and by the one configured', function (): void {
+ config(['schedule.laravel.connection' => 'sqs']);
+ $this->engine->place(new Event('digest', time() + 7200));
+
+ config(['schedule.laravel.connection' => 'database', 'schedule.laravel.max_delay' => 60]);
+ $this->engine->place(new Event('other', time() + 7200));
+
+ expect(dispatchedJobs()[0]->delay)->toBe(900)
+ ->and(dispatchedJobs()[1]->delay)->toBe(60);
+ });
+
+ it('cancels by taking the entry out of the index, without reading the queue', function (): void {
+ $this->engine->place(new Event('digest', time() + 3600, null, ['u' => 1]));
+
+ expect($this->engine->cancel('digest', ['u' => 1]))->toBe(1)
+ ->and(CronIndex::ownerOf('digest', ['u' => 1]))->toBeNull()
+ ->and($this->engine->next('digest', ['u' => 1]))->toBeNull();
+ });
+
+ it('gives a recurring event the interval of its recurrence when the caller gave none: the fluent API and #[Schedule] only know the name', function (): void {
+ $this->engine->place(new Event('reports', time() - 5, 'daily', []));
+
+ expect(dispatchedJobs()[0]->interval)->toBe(86400);
+
+ $job = dispatchedJobs()[0];
+ $job->handle($this->driver);
+
+ $next = CronIndex::events('laravel');
+
+ expect($next)->toHaveCount(1)
+ ->and($next[0]->hook)->toBe('reports')
+ ->and($next[0]->timestamp)->toBe($job->timestamp + 86400);
+ });
+
+ it('stores a single event of the WordPress functions the same way', function (): void {
+ $this->driver->once('digest', time() + 600, []);
+ $this->driver->recurring('reports', time() + 600, 'daily', []);
+
+ expect(CronIndex::events('laravel'))->toHaveCount(2)
+ ->and(CronIndex::events('laravel')[0]->id)->not->toBeNull()
+ ->and(dispatchedJobs()[1]->interval)->toBe(86400);
+ });
+});
+
+describe('the job', function (): void {
+ it('fires the hook and takes a single event out of the index', function (): void {
+ $this->engine->place(new Event('digest', time() - 1, null, ['u' => 1]));
+
+ dispatchedJobs()[0]->handle($this->driver);
+
+ expect($this->fired)->toBe([['digest', ['u' => 1]]])
+ ->and(CronIndex::events('laravel'))->toBe([]);
+ });
+
+ it('queues the next occurrence of a recurring event, never in the past', function (): void {
+ $this->engine->place(new Event('reports', time() - 10000, 'hourly', [], 3600));
+ $job = dispatchedJobs()[0];
+
+ $job->handle($this->driver);
+
+ $next = CronIndex::events('laravel')[0];
+
+ expect($this->fired)->toHaveCount(1)
+ ->and($next->timestamp)->toBeGreaterThan(time())
+ ->and($next->timestamp)->toBeLessThanOrEqual(time() + 3600)
+ ->and(($next->timestamp - $job->timestamp) % 3600)->toBe(0)
+ ->and($next->id)->not->toBe($job->id)
+ ->and(dispatchedJobs())->toHaveCount(2)
+ ->and(dispatchedJobs()[1]->id)->toBe($next->id);
+ });
+
+ it('stops when its event was cancelled, moved or replaced', function (): void {
+ $this->engine->place(new Event('digest', time() - 1));
+ $this->engine->place(new Event('moved', time() - 1));
+ $this->engine->place(new Event('replaced', time() - 1));
+ [$cancelled, $moved, $replaced] = dispatchedJobs();
+
+ $this->engine->cancel('digest');
+ CronIndex::record((new Event('moved', time() + 5000))->withId($moved->id), 'laravel');
+ CronIndex::record((new Event('replaced', time() - 1))->withId('another'), 'laravel');
+
+ $cancelled->handle($this->driver);
+ $moved->handle($this->driver);
+ $replaced->handle($this->driver);
+
+ expect($this->fired)->toBe([]);
+ });
+
+ it('waits again when it wakes before its time, and runs only once when sent twice', function (): void {
+ $this->engine->place(new Event('digest', time() + 3600));
+ $early = dispatchedJobs()[0];
+
+ $early->handle($this->driver);
+
+ expect($this->fired)->toBe([])
+ ->and(dispatchedJobs())->toHaveCount(2)
+ ->and(dispatchedJobs()[1]->id)->toBe($early->id)
+ ->and(dispatchedJobs()[1]->timestamp)->toBe($early->timestamp);
+
+ $this->cron = [];
+ $this->engine->place(new Event('again', time() - 1));
+ $first = dispatchedJobs()[2];
+ $copy = clone $first;
+
+ $first->handle($this->driver);
+ $copy->handle($this->driver);
+
+ expect($this->fired)->toBe([['again', []]]);
+ });
+
+ it('records a final failure, and goes on with a recurring event', function (): void {
+ $this->engine->place(new Event('reports', time() - 10, 'hourly', [], 3600));
+ $this->engine->place(new Event('digest', time() - 10));
+ [$recurring, $single] = dispatchedJobs();
+
+ $recurring->failed(new RuntimeException('boom'));
+ $single->failed(new RuntimeException('bang'));
+
+ expect(array_column(ScheduleFailures::recent(), 'hook'))->toBe(['reports', 'digest'])
+ ->and(ScheduleFailures::recent()[1]['message'])->toBe('bang')
+ ->and(array_column(CronIndex::events('laravel'), 'hook'))->toBe(['reports'])
+ ->and(dispatchedJobs())->toHaveCount(3);
+ });
+
+ it('runs in the site that scheduled the event on a network', function (): void {
+ $switched = [];
+ Functions\when('is_multisite')->justReturn(true);
+ Functions\when('switch_to_blog')->alias(function (int $site) use (&$switched): bool {
+ $switched[] = $site;
+
+ return true;
+ });
+ Functions\when('restore_current_blog')->alias(function () use (&$switched): bool {
+ $switched[] = 'restore';
+
+ return true;
+ });
+
+ $this->engine->place(new Event('digest', time() - 1));
+ $job = dispatchedJobs()[0];
+ $job = new RunScheduledEvent($job->hook, $job->args, $job->timestamp, $job->id, null, null, 3);
+
+ $job->handle($this->driver);
+
+ expect($switched)->toBe([3, 'restore']);
+ });
+});
+
+describe('the recovery', function (): void {
+ it('sends again the events more than late_after seconds past their time, with the same identifier, once', function (): void {
+ $this->engine->place(new Event('lost', time() - 1200));
+ $this->engine->place(new Event('fresh', time() - 30));
+ $this->engine->place(new Event('future', time() + 3000));
+
+ $queued = count(dispatchedJobs());
+
+ $recovery = resolve(LateEventRecovery::class);
+ $sent = $recovery->recover();
+
+ expect($sent)->toBe(1)
+ ->and(dispatchedJobs())->toHaveCount($queued + 1)
+ ->and(dispatchedJobs()[$queued]->hook)->toBe('lost')
+ ->and(dispatchedJobs()[$queued]->id)->toBe(CronIndex::idOf('lost'))
+ ->and($recovery->recover())->toBe(0)
+ ->and($recovery->recover(force: true))->toBe(1);
+ });
+
+ it('gives an event of the index that has no identifier one', function (): void {
+ $this->cron = [time() - 5000 => ['lost' => [md5(serialize([])) => ['schedule' => false, 'args' => [], 'driver' => 'laravel']]]];
+
+ expect(resolve(LateEventRecovery::class)->recover(force: true))->toBe(1)
+ ->and(CronIndex::idOf('lost'))->not->toBeNull();
+ });
+
+ it('runs from the command, and lists with --dry-run', function (): void {
+ $this->engine->place(new Event('lost', time() - 1200));
+
+ expect(Artisan::call('pollora:schedule:recover', ['--dry-run' => true]))->toBe(0)
+ ->and(Artisan::output())->toContain('lost', '1 late event(s).')
+ ->and(dispatchedJobs())->toHaveCount(1)
+ ->and(Artisan::call('pollora:schedule:recover'))->toBe(0)
+ ->and(Artisan::output())->toContain('1 event(s) sent again.')
+ ->and(dispatchedJobs())->toHaveCount(2);
+ });
+});
+
+describe('the doctor check', function (): void {
+ it('warns of late events, and of a failure that left a single event out of the schedule', function (): void {
+ $this->engine->place(new Event('lost', time() - 1200));
+ ScheduleFailures::record('digest', 'bang');
+
+ $result = resolve(ScheduleDriverCheck::class)->run(RunContext::Console);
+
+ expect($result->status)->toBe(CheckStatus::Warning)
+ ->and(implode(' ', $result->details))->toContain('1 scheduled event(s) are more than 10 minutes late', '"digest" failed for good')
+ ->and($result->fix)->toContain('queue:work');
+ });
+
+ it('says what to change when the queue is sync', function (): void {
+ config(['schedule.laravel.connection' => 'sync']);
+
+ $result = resolve(ScheduleDriverCheck::class)->run(RunContext::Console);
+
+ expect($result->status)->toBe(CheckStatus::Warning)
+ ->and($result->fix)->toContain('never sync or null');
+ });
+});
diff --git a/tests/Feature/Schedule/ScheduleCommandsTest.php b/tests/Feature/Schedule/ScheduleCommandsTest.php
new file mode 100644
index 00000000..85cc56b6
--- /dev/null
+++ b/tests/Feature/Schedule/ScheduleCommandsTest.php
@@ -0,0 +1,338 @@
+ */
+ public array $kept = [];
+
+ public function available(): bool
+ {
+ return self::$available;
+ }
+
+ public function once(string $hook, int $timestamp, array $args = []): bool
+ {
+ $this->kept[] = new Event($hook, $timestamp, null, $args);
+
+ return true;
+ }
+
+ public function recurring(string $hook, int $timestamp, string $recurrence, array $args = []): bool
+ {
+ $this->kept[] = new Event($hook, $timestamp, $recurrence, $args, 3600);
+
+ return true;
+ }
+
+ public function cancel(string $hook, array $args = []): int
+ {
+ $before = count($this->kept);
+ $this->kept = array_values(array_filter($this->kept, fn (Event $event): bool => $event->hook !== $hook || $event->args !== $args));
+
+ return $before - count($this->kept);
+ }
+
+ public function clear(string $hook): int
+ {
+ $before = count($this->kept);
+ $this->kept = array_values(array_filter($this->kept, fn (Event $event): bool => $event->hook !== $hook));
+
+ return $before - count($this->kept);
+ }
+
+ public function next(string $hook, array $args = []): ?int
+ {
+ foreach ($this->kept as $event) {
+ if ($event->hook === $hook && $event->args === $args) {
+ return $event->timestamp;
+ }
+ }
+
+ return null;
+ }
+
+ public function events(): array
+ {
+ return $this->kept;
+ }
+}
+
+beforeEach(function (): void {
+ $schedule = new ReflectionClass(Schedule::class);
+
+ foreach (['engine', 'fake', 'driverBeforeFake', 'wanted'] as $property) {
+ $schedule->setStaticPropertyValue($property, null);
+ }
+
+ MemoryScheduleDriver::$available = true;
+ $this->memory = new MemoryScheduleDriver;
+ $this->time = time() + 3600;
+ $this->cron = [
+ $this->time => [
+ 'reports' => [md5(serialize([])) => ['schedule' => 'daily', 'args' => [], 'interval' => 86400]],
+ 'wp_version_check' => [md5(serialize([])) => ['schedule' => 'twicedaily', 'args' => [], 'interval' => 43200]],
+ ],
+ $this->time + 60 => [
+ 'digest' => [md5(serialize(['u' => 1])) => ['schedule' => false, 'args' => ['u' => 1], 'driver' => 'memory']],
+ 'action_scheduler_run_queue' => [md5(serialize([])) => ['schedule' => 'every_minute', 'args' => [], 'interval' => 60]],
+ ],
+ ];
+
+ Functions\when('_get_cron_array')->alias(fn (): array => $this->cron);
+ Functions\when('_set_cron_array')->alias(function (array $cron): bool {
+ $this->cron = $cron;
+
+ return true;
+ });
+ Functions\when('wp_get_schedules')->justReturn(['daily' => ['interval' => 86400], 'twicedaily' => ['interval' => 43200]]);
+ Functions\when('wp_schedule_event')->justReturn(true);
+ Functions\when('wp_schedule_single_event')->justReturn(true);
+ Functions\when('did_action')->justReturn(1);
+ Functions\when('get_current_blog_id')->justReturn(1);
+ Functions\when('is_multisite')->justReturn(false);
+ Functions\when('wp_date')->alias(fn (string $format, int $timestamp): string => gmdate($format, $timestamp));
+
+ Schedule::extend('memory', fn (): ScheduleDriver => $this->memory);
+ config(['schedule.driver' => 'memory', 'schedule.protected_hooks' => []]);
+ app()->register(SchedulerServiceProvider::class, force: true);
+});
+
+afterEach(function (): void {
+ MemoryScheduleDriver::$available = true;
+});
+
+/**
+ * @return list>
+ */
+function scheduleListJson(array $options = []): array
+{
+ expect(Artisan::call('pollora:schedule:list', [...$options, '--json' => true]))->toBe(0);
+
+ return json_decode(Artisan::output(), true);
+}
+
+describe('driver choice', function (): void {
+ it('chooses the driver config/schedule.php names, and merges the defaults', function (): void {
+ expect(Schedule::engine()->driverName())->toBe('memory')
+ ->and(require __DIR__.'/../../../config/schedule.php')->toMatchArray(['protected_hooks' => []])
+ ->and(config('schedule.driver'))->toBe('memory');
+ });
+
+ it('falls back to WP-Cron when it names a driver that does not exist, and says so', function (): void {
+ config(['schedule.driver' => 'nope']);
+ $schedule = new ReflectionClass(Schedule::class);
+ $schedule->setStaticPropertyValue('engine', null);
+
+ app()->register(SchedulerServiceProvider::class, force: true);
+
+ $status = resolve(ScheduleStatus::class);
+
+ expect(Schedule::engine()->driverName())->toBe('wp-cron')
+ ->and($status->unknown())->toBeTrue()
+ ->and($status->configured())->toBe('nope');
+ });
+
+ it('does not call a driver unknown because something else switched the engine after the provider', function (): void {
+ Schedule::driver('wp-cron');
+
+ $status = resolve(ScheduleStatus::class);
+
+ expect($status->configured())->toBe('memory')
+ ->and($status->active())->toBe('wp-cron')
+ ->and($status->unknown())->toBeFalse();
+ });
+
+ it('protects the hooks the configuration names', function (): void {
+ config(['schedule.protected_hooks' => ['my_heartbeat']]);
+ app()->register(SchedulerServiceProvider::class, force: true);
+
+ expect(Schedule::engine()->protectedHooks())->toContain('my_heartbeat', 'action_scheduler_run_queue');
+ });
+});
+
+describe('pollora:schedule:list', function (): void {
+ it('lists every event with its type and its driver, soonest first, from the index alone', function (): void {
+ $rows = scheduleListJson();
+
+ expect(array_column($rows, 'hook'))->toBe(['reports', 'wp_version_check', 'digest', 'action_scheduler_run_queue'])
+ ->and($rows[2])->toMatchArray(['type' => 'once', 'driver' => 'memory', 'driver_label' => 'memory', 'args' => ['u' => 1]])
+ ->and($rows[0])->toMatchArray(['type' => 'recurring', 'recurrence' => 'daily', 'frequency' => 'daily', 'driver' => 'wp-cron']);
+ });
+
+ it('marks what is on WP-Cron on purpose, and what is left to migrate', function (): void {
+ $labels = array_column(scheduleListJson(), 'driver_label', 'hook');
+
+ expect($labels['action_scheduler_run_queue'])->toBe('wp-cron (protected)')
+ ->and($labels['reports'])->toBe('wp-cron (to migrate)')
+ ->and($labels['digest'])->toBe('memory');
+
+ MemoryScheduleDriver::$available = false;
+
+ expect(array_column(scheduleListJson(), 'driver_label', 'hook')['reports'])->toBe('wp-cron (fallback)');
+ });
+
+ it('filters by type and by driver, and refuses an unknown type', function (): void {
+ expect(array_column(scheduleListJson(['--type' => 'once']), 'hook'))->toBe(['digest'])
+ ->and(array_column(scheduleListJson(['--driver' => 'wp-cron', '--type' => 'recurring']), 'hook'))->toBe(['reports', 'wp_version_check', 'action_scheduler_run_queue'])
+ ->and(Artisan::call('pollora:schedule:list', ['--type' => 'sometimes']))->toBe(1);
+ });
+
+ it('prints a table, and a line when nothing matches', function (): void {
+ expect(Artisan::call('pollora:schedule:list'))->toBe(0)
+ ->and(Artisan::output())->toContain('wp-cron (to migrate)', 'digest');
+
+ Artisan::call('pollora:schedule:list', ['--driver' => 'nobody']);
+
+ expect(Artisan::output())->toContain('No scheduled event matches.');
+ });
+
+ it('writes a frequency a person reads for a custom interval', function (): void {
+ $this->cron[$this->time]['fast'] = [md5(serialize([])) => ['schedule' => 'custom_x', 'args' => [], 'interval' => 900]];
+
+ expect(array_column(scheduleListJson(), 'frequency', 'hook')['fast'])->toBe('15 min');
+ });
+});
+
+describe('pollora:schedule:migrate', function (): void {
+ it('shows what would move, and moves nothing, with --dry-run', function (): void {
+ expect(Artisan::call('pollora:schedule:migrate', ['--dry-run' => true]))->toBe(0)
+ ->and(Artisan::output())->toContain('Would move reports', 'Kept action_scheduler_run_queue')
+ ->and($this->memory->kept)->toBe([]);
+ });
+
+ it('moves the events of WP-Cron to the driver of the site', function (): void {
+ expect(Artisan::call('pollora:schedule:migrate'))->toBe(0)
+ ->and(array_column($this->memory->kept, 'hook'))->toContain('reports', 'wp_version_check')
+ ->and(array_column(scheduleListJson(), 'driver_label', 'hook')['reports'])->toBe('memory');
+ });
+
+ it('moves them back with --from and --to, and guesses the source when the target is WP-Cron', function (): void {
+ Artisan::call('pollora:schedule:migrate');
+
+ expect(Artisan::call('pollora:schedule:migrate', ['--to' => 'wp-cron']))->toBe(0)
+ ->and(array_column(scheduleListJson(), 'driver', 'hook')['reports'])->toBe('wp-cron');
+ });
+
+ it('fails when the target is not there, or there is no other driver to take from', function (): void {
+ MemoryScheduleDriver::$available = false;
+
+ expect(Artisan::call('pollora:schedule:migrate'))->toBe(1)
+ ->and(Artisan::output())->toContain('not available');
+
+ MemoryScheduleDriver::$available = true;
+ $this->cron = [];
+
+ expect(Artisan::call('pollora:schedule:migrate', ['--to' => 'wp-cron']))->toBe(1)
+ ->and(Artisan::output())->toContain('say which one with --from');
+ });
+});
+
+describe('the doctor check', function (): void {
+ it('is ok when events are kept by the driver of the site', function (): void {
+ $this->cron = [$this->time => ['digest' => [md5(serialize([])) => ['schedule' => false, 'args' => [], 'driver' => 'memory']]]];
+
+ $result = resolve(ScheduleDriverCheck::class)->run(RunContext::Console);
+
+ expect($result->status)->toBe(CheckStatus::Ok);
+ });
+
+ it('warns of events a former driver keeps, with the command that moves them', function (): void {
+ $result = resolve(ScheduleDriverCheck::class)->run(RunContext::Console);
+
+ expect($result->status)->toBe(CheckStatus::Warning)
+ ->and(implode(' ', $result->details))->toContain('"wp-cron"', 'not by the driver of the site ("memory")')
+ ->and($result->fix)->toContain('pollora:schedule:migrate --from=wp-cron --to=memory');
+ });
+
+ it('warns of a driver that is not there, and does not ask to migrate what falls back to WP-Cron', function (): void {
+ MemoryScheduleDriver::$available = false;
+ $this->cron = [$this->time => ['reports' => [md5(serialize([])) => ['schedule' => 'daily', 'args' => [], 'interval' => 86400]]]];
+
+ $result = resolve(ScheduleDriverCheck::class)->run(RunContext::Console);
+
+ expect($result->status)->toBe(CheckStatus::Warning)
+ ->and(implode(' ', $result->details))->toContain('is not available')
+ ->and(implode(' ', $result->details))->not->toContain('still kept');
+ });
+
+ it('warns of a driver the configuration names and nobody provides', function (): void {
+ config(['schedule.driver' => 'nope']);
+ (new ReflectionClass(Schedule::class))->setStaticPropertyValue('engine', null);
+ app()->register(SchedulerServiceProvider::class, force: true);
+
+ $result = resolve(ScheduleDriverCheck::class)->run(RunContext::Console);
+
+ expect($result->status)->toBe(CheckStatus::Warning)
+ ->and(implode(' ', $result->details))->toContain('"nope", which does not exist');
+ });
+
+ it('is skipped before WordPress has initialised', function (): void {
+ Functions\when('did_action')->justReturn(0);
+
+ expect(resolve(ScheduleDriverCheck::class)->run(RunContext::Console)->status)->toBe(CheckStatus::Skipped);
+ });
+});
+
+describe('the administration notice', function (): void {
+ beforeEach(function (): void {
+ Functions\when('esc_html__')->returnArg();
+ Functions\when('esc_html')->returnArg();
+ });
+
+ it('tells those who can run the command which events wait, and how to move them', function (): void {
+ Functions\when('current_user_can')->justReturn(true);
+
+ ob_start();
+ resolve(MigrationNotice::class)->render();
+ $html = (string) ob_get_clean();
+
+ expect($html)->toContain('notice-warning', 'pollora:schedule:migrate --from=wp-cron --to=memory');
+ });
+
+ it('says nothing to those who cannot, or when nothing waits', function (): void {
+ Functions\when('current_user_can')->justReturn(false);
+
+ ob_start();
+ resolve(MigrationNotice::class)->render();
+ expect((string) ob_get_clean())->toBe('');
+
+ Functions\when('current_user_can')->justReturn(true);
+ $this->cron = [];
+
+ ob_start();
+ resolve(MigrationNotice::class)->render();
+ expect((string) ob_get_clean())->toBe('');
+ });
+
+ it('is shown on the administration screens, and the network ones', function (): void {
+ $added = [];
+ Functions\when('add_action')->alias(function (string $hook) use (&$added): bool {
+ $added[] = $hook;
+
+ return true;
+ });
+
+ resolve(MigrationNotice::class)->register();
+
+ expect($added)->toBe(['admin_notices', 'network_admin_notices']);
+ });
+});
diff --git a/tests/Feature/Schedule/SchedulerServiceProviderTest.php b/tests/Feature/Schedule/SchedulerServiceProviderTest.php
index 023f58b3..90084e35 100644
--- a/tests/Feature/Schedule/SchedulerServiceProviderTest.php
+++ b/tests/Feature/Schedule/SchedulerServiceProviderTest.php
@@ -4,10 +4,8 @@
use Pollora\Discovery\Domain\Contracts\DiscoveryEngineInterface;
use Pollora\Hook\Domain\Contract\Filter;
-use Pollora\Schedule\Application\UseCases\RegisterSchedulerFiltersUseCase;
-use Pollora\Schedule\Contracts\SchedulerInterface;
use Pollora\Schedule\Infrastructure\Services\ScheduleDiscovery;
-use Pollora\Schedule\Scheduler;
+use Pollora\Schedule\Schedule;
use Pollora\Schedule\SchedulerServiceProvider;
beforeEach(function (): void {
@@ -22,13 +20,6 @@
});
describe('SchedulerServiceProvider', function (): void {
- it('binds SchedulerInterface as singleton', function (): void {
- $scheduler = $this->app->make(SchedulerInterface::class);
-
- expect($scheduler)->toBeInstanceOf(Scheduler::class);
- expect($this->app->make(SchedulerInterface::class))->toBe($scheduler);
- });
-
it('binds ScheduleDiscovery as singleton', function (): void {
$discovery = $this->app->make(ScheduleDiscovery::class);
@@ -36,9 +27,9 @@
expect($this->app->make(ScheduleDiscovery::class))->toBe($discovery);
});
- it('binds RegisterSchedulerFiltersUseCase', function (): void {
- $useCase = $this->app->make(RegisterSchedulerFiltersUseCase::class);
+ it('gives the schedule engine the application, so the classes passed to call() get their dependencies', function (): void {
+ $container = (new ReflectionProperty(Schedule::engine(), 'container'))->getValue(Schedule::engine());
- expect($useCase)->toBeInstanceOf(RegisterSchedulerFiltersUseCase::class);
+ expect($container)->toBe($this->app);
});
});
diff --git a/tests/Pest.php b/tests/Pest.php
index 37093e09..e5722449 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -17,7 +17,6 @@
uses(TestCase::class)->in('Feature/Modules', 'Feature/Option');
uses(TestCase::class)->in('Unit');
-uses()->in('Benchmark');
uses(Orchestra\Testbench\TestCase::class)
->beforeEach(function (): void {
\Brain\Monkey\setUp();
diff --git a/tests/Unit/Attributes/ActionTest.php b/tests/Unit/Attributes/ActionTest.php
deleted file mode 100644
index 4cd33586..00000000
--- a/tests/Unit/Attributes/ActionTest.php
+++ /dev/null
@@ -1,187 +0,0 @@
-mockAction = Mockery::mock(ActionService::class);
-
- // Create a fake service locator
- $this->mockServiceLocator = new class($this->mockAction)
- {
- public function __construct(private $actionService) {}
-
- public function get($serviceClass)
- {
- if ($serviceClass === ActionService::class) {
- return $this->actionService;
- }
-
- return null;
- }
- };
-});
-
-class SingleActionClass
-{
- #[Action('test_action', priority: 10)]
- public function actionMethod($param = null): string
- {
- // Test method
- return $param ? 'processed_'.$param : 'processed';
- }
-}
-
-class MultipleActionClass
-{
- #[Action('test_action', priority: 10)]
- public function actionMethod($param = null): string
- {
- // Test method
- return $param ? 'processed_'.$param : 'processed';
- }
-
- #[Action('another_action', priority: 20)]
- public function anotherActionMethod(): string
- {
- // Another test method
- return 'another_processed';
- }
-}
-
-class DefaultPriorityActionClass
-{
- #[Action('test_action')]
- public function actionMethod($param = null): string
- {
- // Test method with default priority
- return $param ? 'processed_'.$param : 'processed';
- }
-}
-
-class CustomPriorityActionClass
-{
- #[Action('test_action', priority: 42)]
- public function actionMethod($param = null): string
- {
- // Test method with custom priority
- return $param ? 'processed_'.$param : 'processed';
- }
-}
-
-it('registers an action hook correctly', function (): void {
- // Set up expectations
- $this->mockAction->shouldReceive('add')
- ->once()
- ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_action'
- && is_array($callback)
- && $callback[0] instanceof SingleActionClass
- && $callback[1] === 'actionMethod'
- && $priority === 10
- && $acceptedArgs === 1);
-
- // Test the action attribute directly using handle method
- $testClass = new SingleActionClass;
- $actionAttribute = new Action('test_action', 10);
- $methodReflection = new ReflectionMethod($testClass, 'actionMethod');
-
- $actionAttribute->handle($this->mockServiceLocator, $testClass, $methodReflection, $actionAttribute);
-});
-
-it('registers multiple action hooks with different priorities', function (): void {
- $testClass = new MultipleActionClass;
-
- // Test first action
- $this->mockAction->shouldReceive('add')
- ->once()
- ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_action'
- && is_array($callback)
- && $callback[0] instanceof MultipleActionClass
- && $callback[1] === 'actionMethod'
- && $priority === 10);
-
- $actionAttribute1 = new Action('test_action', 10);
- $methodReflection1 = new ReflectionMethod($testClass, 'actionMethod');
- $actionAttribute1->handle($this->mockServiceLocator, $testClass, $methodReflection1, $actionAttribute1);
-
- // Test second action
- $this->mockAction->shouldReceive('add')
- ->once()
- ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'another_action'
- && is_array($callback)
- && $callback[0] instanceof MultipleActionClass
- && $callback[1] === 'anotherActionMethod'
- && $priority === 20);
-
- $actionAttribute2 = new Action('another_action', 20);
- $methodReflection2 = new ReflectionMethod($testClass, 'anotherActionMethod');
- $actionAttribute2->handle($this->mockServiceLocator, $testClass, $methodReflection2, $actionAttribute2);
-});
-
-it('registers an action hook with default priority (10)', function (): void {
- // Set up expectations
- $this->mockAction->shouldReceive('add')
- ->once()
- ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_action'
- && is_array($callback)
- && $callback[0] instanceof DefaultPriorityActionClass
- && $callback[1] === 'actionMethod'
- && $priority === 10 // Default priority should be 10
- && $acceptedArgs === 1);
-
- // Test with default priority
- $testClass = new DefaultPriorityActionClass;
- $actionAttribute = new Action('test_action'); // No priority specified, should default to 10
- $methodReflection = new ReflectionMethod($testClass, 'actionMethod');
-
- $actionAttribute->handle($this->mockServiceLocator, $testClass, $methodReflection, $actionAttribute);
-});
-
-it('registers an action hook with custom priority', function (): void {
- // Set up expectations
- $this->mockAction->shouldReceive('add')
- ->once()
- ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_action'
- && is_array($callback)
- && $callback[0] instanceof CustomPriorityActionClass
- && $callback[1] === 'actionMethod'
- && $priority === 42 // Custom priority
- && $acceptedArgs === 1);
-
- // Test with custom priority
- $testClass = new CustomPriorityActionClass;
- $actionAttribute = new Action('test_action', 42);
- $methodReflection = new ReflectionMethod($testClass, 'actionMethod');
-
- $actionAttribute->handle($this->mockServiceLocator, $testClass, $methodReflection, $actionAttribute);
-});
-
-it('handles null service locator resolution gracefully', function (): void {
- // Create a service locator that returns null for the service
- $mockServiceLocator = new class
- {
- public function get($serviceClass): null
- {
- return null;
- }
- };
-
- $testClass = new SingleActionClass;
- $actionAttribute = new Action('test_action', 10);
- $methodReflection = new ReflectionMethod($testClass, 'actionMethod');
-
- // This should not throw an exception
- $actionAttribute->handle($mockServiceLocator, $testClass, $methodReflection, $actionAttribute);
-
- // No assertions needed - we're just checking that it doesn't throw
- expect(true)->toBeTrue();
-});
-
-afterEach(function (): void {
- Mockery::close();
- Facade::clearResolvedInstances();
-});
diff --git a/tests/Unit/Attributes/FilterTest.php b/tests/Unit/Attributes/FilterTest.php
deleted file mode 100644
index abf6cd87..00000000
--- a/tests/Unit/Attributes/FilterTest.php
+++ /dev/null
@@ -1,209 +0,0 @@
-mockFilter = Mockery::mock(FilterService::class);
-
- // Create a fake service locator
- $this->mockServiceLocator = new class($this->mockFilter)
- {
- public function __construct(private $filterService) {}
-
- public function get($serviceClass)
- {
- if ($serviceClass === FilterService::class) {
- return $this->filterService;
- }
-
- return null;
- }
- };
-});
-
-class SingleFilterClass
-{
- #[Filter('test_filter', priority: 10)]
- public function filterMethod(string $value): string
- {
- return 'modified_'.$value;
- }
-}
-
-class MultipleFilterClass
-{
- #[Filter('test_filter', priority: 10)]
- public function filterMethod(string $value): string
- {
- return 'modified_'.$value;
- }
-
- #[Filter('another_filter', priority: 20)]
- public function anotherFilterMethod(array $data): array
- {
- $data['modified'] = true;
-
- return $data;
- }
-}
-
-class DefaultPriorityFilterClass
-{
- #[Filter('test_filter')]
- public function filterMethod(string $value): string
- {
- // Test method with default priority
- return 'modified_'.$value;
- }
-}
-
-class CustomPriorityFilterClass
-{
- #[Filter('test_filter', priority: 99)]
- public function filterMethod(string $value): string
- {
- // Test method with custom priority
- return 'modified_'.$value;
- }
-}
-
-it('registers a filter hook correctly', function (): void {
- // Set up expectations
- $this->mockFilter->shouldReceive('add')
- ->once()
- ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_filter'
- && is_array($callback)
- && $callback[0] instanceof SingleFilterClass
- && $callback[1] === 'filterMethod'
- && $priority === 10
- && $acceptedArgs === 1);
-
- // Test the filter attribute directly using handle method
- $testClass = new SingleFilterClass;
- $filterAttribute = new Filter('test_filter', 10);
- $methodReflection = new ReflectionMethod($testClass, 'filterMethod');
-
- $filterAttribute->handle($this->mockServiceLocator, $testClass, $methodReflection, $filterAttribute);
-});
-
-it('registers multiple filter hooks with different priorities', function (): void {
- $testClass = new MultipleFilterClass;
-
- // Test first filter
- $this->mockFilter->shouldReceive('add')
- ->once()
- ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_filter'
- && is_array($callback)
- && $callback[0] instanceof MultipleFilterClass
- && $callback[1] === 'filterMethod'
- && $priority === 10);
-
- $filterAttribute1 = new Filter('test_filter', 10);
- $methodReflection1 = new ReflectionMethod($testClass, 'filterMethod');
- $filterAttribute1->handle($this->mockServiceLocator, $testClass, $methodReflection1, $filterAttribute1);
-
- // Test second filter
- $this->mockFilter->shouldReceive('add')
- ->once()
- ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'another_filter'
- && is_array($callback)
- && $callback[0] instanceof MultipleFilterClass
- && $callback[1] === 'anotherFilterMethod'
- && $priority === 20);
-
- $filterAttribute2 = new Filter('another_filter', 20);
- $methodReflection2 = new ReflectionMethod($testClass, 'anotherFilterMethod');
- $filterAttribute2->handle($this->mockServiceLocator, $testClass, $methodReflection2, $filterAttribute2);
-});
-
-it('registers a filter hook with default priority (10)', function (): void {
- // Set up expectations
- $this->mockFilter->shouldReceive('add')
- ->once()
- ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_filter'
- && is_array($callback)
- && $callback[0] instanceof DefaultPriorityFilterClass
- && $callback[1] === 'filterMethod'
- && $priority === 10 // Default priority should be 10
- && $acceptedArgs === 1);
-
- // Test with default priority
- $testClass = new DefaultPriorityFilterClass;
- $filterAttribute = new Filter('test_filter'); // No priority specified, should default to 10
- $methodReflection = new ReflectionMethod($testClass, 'filterMethod');
-
- $filterAttribute->handle($this->mockServiceLocator, $testClass, $methodReflection, $filterAttribute);
-});
-
-it('registers a filter hook with custom priority', function (): void {
- // Set up expectations
- $this->mockFilter->shouldReceive('add')
- ->once()
- ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_filter'
- && is_array($callback)
- && $callback[0] instanceof CustomPriorityFilterClass
- && $callback[1] === 'filterMethod'
- && $priority === 99 // Custom priority
- && $acceptedArgs === 1);
-
- // Test with custom priority
- $testClass = new CustomPriorityFilterClass;
- $filterAttribute = new Filter('test_filter', 99);
- $methodReflection = new ReflectionMethod($testClass, 'filterMethod');
-
- $filterAttribute->handle($this->mockServiceLocator, $testClass, $methodReflection, $filterAttribute);
-});
-
-it('executes filter and returns modified value', function (): void {
- // Set up expectations for the add method
- $this->mockFilter->shouldReceive('add')
- ->once()
- ->withAnyArgs();
-
- // Set up expectations for the apply method
- $this->mockFilter->shouldReceive('apply')
- ->once()
- ->with('test_filter', 'original')
- ->andReturn('modified_original');
-
- // Test filter attribute directly
- $testClass = new SingleFilterClass;
- $filterAttribute = new Filter('test_filter', 10);
- $methodReflection = new ReflectionMethod($testClass, 'filterMethod');
- $filterAttribute->handle($this->mockServiceLocator, $testClass, $methodReflection, $filterAttribute);
-
- // Test the apply method
- $result = $this->mockFilter->apply('test_filter', 'original');
- expect($result)->toBe('modified_original');
-});
-
-it('handles null service locator resolution gracefully', function (): void {
- // Create a service locator that returns null for the service
- $mockServiceLocator = new class
- {
- public function get($serviceClass): null
- {
- return null;
- }
- };
-
- $testClass = new SingleFilterClass;
- $filterAttribute = new Filter('test_filter', 10);
- $methodReflection = new ReflectionMethod($testClass, 'filterMethod');
-
- // This should not throw an exception
- $filterAttribute->handle($mockServiceLocator, $testClass, $methodReflection, $filterAttribute);
-
- // No assertions needed - we're just checking that it doesn't throw
- expect(true)->toBeTrue();
-});
-
-afterEach(function (): void {
- Mockery::close();
- Facade::clearResolvedInstances();
-});
diff --git a/tests/Unit/Attributes/HookTest.php b/tests/Unit/Attributes/HookTest.php
deleted file mode 100644
index 72b02c9c..00000000
--- a/tests/Unit/Attributes/HookTest.php
+++ /dev/null
@@ -1,66 +0,0 @@
-hook)->toBe('test_hook');
- expect($hook->priority)->toBe(10);
-});
-
-it('initializes with custom priority', function (): void {
- $hook = new ConcreteHook('test_hook', 20);
- expect($hook->hook)->toBe('test_hook');
- expect($hook->priority)->toBe(20);
-});
-
-it('stores hook name correctly', function (): void {
- $hookName = 'custom_hook_name';
- $hook = new ConcreteHook($hookName);
- expect($hook->hook)->toBe($hookName);
-});
-
-it('allows priority to be a negative number', function (): void {
- $priority = -1;
- $hook = new ConcreteHook('test_hook', $priority);
- expect($hook->priority)->toBe($priority);
-});
-
-it('allows priority to be zero', function (): void {
- $priority = 0;
- $hook = new ConcreteHook('test_hook', $priority);
- expect($hook->priority)->toBe($priority);
-});
-
-it('allows priority to be a large number', function (): void {
- $priority = 9999;
- $hook = new ConcreteHook('test_hook', $priority);
- expect($hook->priority)->toBe($priority);
-});
-
-afterEach(function (): void {
- Mockery::close();
- Facade::clearResolvedInstances();
-});
diff --git a/tests/Unit/Attributes/LabelsAttributeTest.php b/tests/Unit/Attributes/LabelsAttributeTest.php
index 84bde720..2f6b4af1 100644
--- a/tests/Unit/Attributes/LabelsAttributeTest.php
+++ b/tests/Unit/Attributes/LabelsAttributeTest.php
@@ -56,9 +56,7 @@ class GenreWithLabels {}
$reflection = new ReflectionClass(ProjectWithLabels::class);
$attr = $reflection->getAttributes(PostTypeLabels::class)[0]->newInstance();
- $attr->handle(null, $config, $reflection, $attr);
-
- $labels = $config->attributeArgs['labels'];
+ $labels = $attr->configureArguments($config->attributeArgs)['labels'];
// Overridden by #[Labels]
expect($labels['all_items'])->toBe('All Projects');
@@ -88,10 +86,7 @@ class GenreWithLabels {}
);
$config = new PostTypeConfiguration('item', 'Item', 'Items');
- $reflection = new ReflectionClass(ArticleWithoutLabels::class);
- $labels->handle(null, $config, $reflection, $labels);
-
- $result = $config->attributeArgs['labels'];
+ $result = $labels->configureArguments($config->attributeArgs)['labels'];
expect($result)->toBe([
'name' => 'My Items',
@@ -126,9 +121,7 @@ class GenreWithLabels {}
$reflection = new ReflectionClass(GenreWithLabels::class);
$attr = $reflection->getAttributes(TaxonomyLabels::class)[0]->newInstance();
- $attr->handle(null, $config, $reflection, $attr);
-
- $labels = $config->attributeArgs['labels'];
+ $labels = $attr->configureArguments($config->attributeArgs)['labels'];
// Overridden
expect($labels['all_items'])->toBe('All Genres');
diff --git a/tests/Unit/Attributes/PostTypeAttributeTest.php b/tests/Unit/Attributes/PostTypeAttributeTest.php
deleted file mode 100644
index 2c0cd6b9..00000000
--- a/tests/Unit/Attributes/PostTypeAttributeTest.php
+++ /dev/null
@@ -1,344 +0,0 @@
- 'Test Product'];
- }
-}
-
-it('creates PostType attribute with all parameters', function (): void {
- $postType = new PostType('custom-slug', 'Custom Type', 'Custom Types');
-
- expect($postType->slug)->toBe('custom-slug');
- expect($postType->singular)->toBe('Custom Type');
- expect($postType->plural)->toBe('Custom Types');
-});
-
-it('creates PostType attribute with only slug', function (): void {
- $postType = new PostType('events');
-
- expect($postType->slug)->toBe('events');
- expect($postType->singular)->toBeNull();
- expect($postType->plural)->toBeNull();
-});
-
-it('creates PostType attribute with no parameters', function (): void {
- $postType = new PostType;
-
- expect($postType->slug)->toBeNull();
- expect($postType->singular)->toBeNull();
- expect($postType->plural)->toBeNull();
-});
-
-it('stores PostType properties as readonly', function (): void {
- $postType = new PostType('test');
-
- expect($postType->slug)->toBe('test');
-
- // Verify properties are readonly
- $reflection = new ReflectionClass($postType);
- $slugProperty = $reflection->getProperty('slug');
- $singularProperty = $reflection->getProperty('singular');
- $pluralProperty = $reflection->getProperty('plural');
-
- expect($slugProperty->isReadOnly())->toBeTrue();
- expect($singularProperty->isReadOnly())->toBeTrue();
- expect($pluralProperty->isReadOnly())->toBeTrue();
-});
-
-it('creates HasArchive attribute with default value', function (): void {
- $hasArchive = new HasArchive;
-
- expect($hasArchive->value)->toBeTrue();
-});
-
-it('creates HasArchive attribute with custom slug', function (): void {
- $hasArchive = new HasArchive('custom-archive');
-
- expect($hasArchive->value)->toBe('custom-archive');
-});
-
-it('creates HasArchive attribute disabled', function (): void {
- $hasArchive = new HasArchive(false);
-
- expect($hasArchive->value)->toBeFalse();
-});
-
-it('creates Supports attribute with default features', function (): void {
- $supports = new Supports;
-
- expect($supports->features)->toBe(['title', 'editor']);
-});
-
-it('creates Supports attribute with custom features', function (): void {
- $features = ['title', 'editor', 'thumbnail', 'excerpt'];
- $supports = new Supports($features);
-
- expect($supports->features)->toBe($features);
-});
-
-it('creates MenuIcon attribute with dashicon', function (): void {
- $menuIcon = new MenuIcon('dashicons-admin-post');
-
- expect($menuIcon->value)->toBe('dashicons-admin-post');
-});
-
-it('creates MenuIcon attribute with custom URL', function (): void {
- $menuIcon = new MenuIcon('https://example.com/icon.png');
-
- expect($menuIcon->value)->toBe('https://example.com/icon.png');
-});
-
-it('creates PubliclyQueryable attribute with default value', function (): void {
- $publiclyQueryable = new PubliclyQueryable;
-
- expect($publiclyQueryable->value)->toBeTrue();
-});
-
-it('creates PubliclyQueryable attribute disabled', function (): void {
- $publiclyQueryable = new PubliclyQueryable(false);
-
- expect($publiclyQueryable->value)->toBeFalse();
-});
-
-it('creates AdminCol attribute with all parameters', function (): void {
- $adminCol = new AdminCol(
- 'price',
- 'Product Price',
- sortable: true,
- titleIcon: 'dashicons-money',
- dateFormat: 'd/m/Y',
- link: 'edit',
- cap: 'edit_posts',
- default: 'ASC',
- width: 120
- );
-
- expect($adminCol->key)->toBe('price');
- expect($adminCol->title)->toBe('Product Price');
- expect($adminCol->width)->toBe(120);
- expect($adminCol->sortable)->toBeTrue();
- expect($adminCol->titleIcon)->toBe('dashicons-money');
- expect($adminCol->dateFormat)->toBe('d/m/Y');
- expect($adminCol->link)->toBe('edit');
- expect($adminCol->cap)->toBe('edit_posts');
- expect($adminCol->default)->toBe('ASC');
-});
-
-it('creates AdminCol attribute with minimal parameters', function (): void {
- $adminCol = new AdminCol('title', 'Title');
-
- expect($adminCol->key)->toBe('title');
- expect($adminCol->title)->toBe('Title');
- expect($adminCol->width)->toBeNull();
- expect($adminCol->sortable)->toBeFalse();
- expect($adminCol->titleIcon)->toBeNull();
- expect($adminCol->dateFormat)->toBeNull();
- expect($adminCol->link)->toBeNull();
- expect($adminCol->cap)->toBeNull();
- expect($adminCol->default)->toBeNull();
-});
-
-it('creates AdminCol attribute for meta fields', function (): void {
- $adminCol = new AdminCol(
- 'custom_price',
- 'Price',
- sortable: 'meta_value_num',
- metaKey: 'product_price'
- );
-
- expect($adminCol->key)->toBe('custom_price');
- expect($adminCol->title)->toBe('Price');
- expect($adminCol->sortable)->toBe('meta_value_num');
- expect($adminCol->metaKey)->toBe('product_price');
-});
-
-it('creates AdminCol attribute for taxonomy fields', function (): void {
- $adminCol = new AdminCol(
- 'categories',
- 'Categories',
- taxonomy: 'product_category'
- );
-
- expect($adminCol->key)->toBe('categories');
- expect($adminCol->title)->toBe('Categories');
- expect($adminCol->taxonomy)->toBe('product_category');
-});
-
-it('creates AdminCol attribute for featured image', function (): void {
- $adminCol = new AdminCol(
- 'image',
- 'Featured Image',
- featuredImage: 'thumbnail',
- width: 80
- );
-
- expect($adminCol->key)->toBe('image');
- expect($adminCol->title)->toBe('Featured Image');
- expect($adminCol->featuredImage)->toBe('thumbnail');
- expect($adminCol->width)->toBe(80);
-});
-
-it('creates RegisterMetaBoxCb attribute', function (): void {
- $registerMetaBoxCb = new RegisterMetaBoxCb;
-
- expect($registerMetaBoxCb)->toBeInstanceOf(RegisterMetaBoxCb::class);
-});
-
-it('has correct PHP attribute configurations', function (): void {
- // Test PostType attribute configuration
- $postTypeReflection = new ReflectionClass(PostType::class);
- $postTypeAttributes = $postTypeReflection->getAttributes(Attribute::class);
-
- expect($postTypeAttributes)->toHaveCount(1);
- $postTypeAttribute = $postTypeAttributes[0]->newInstance();
- expect($postTypeAttribute->flags)->toBe(Attribute::TARGET_CLASS);
-
- // Test AdminCol attribute configuration
- $adminColReflection = new ReflectionClass(AdminCol::class);
- $adminColAttributes = $adminColReflection->getAttributes(Attribute::class);
-
- expect($adminColAttributes)->toHaveCount(1);
- $adminColAttribute = $adminColAttributes[0]->newInstance();
- expect($adminColAttribute->flags)->toBe(Attribute::TARGET_METHOD);
-
- // Test RegisterMetaBoxCb attribute configuration
- $registerMetaBoxCbReflection = new ReflectionClass(RegisterMetaBoxCb::class);
- $registerMetaBoxCbAttributes = $registerMetaBoxCbReflection->getAttributes(Attribute::class);
-
- expect($registerMetaBoxCbAttributes)->toHaveCount(1);
- $registerMetaBoxCbAttribute = $registerMetaBoxCbAttributes[0]->newInstance();
- expect($registerMetaBoxCbAttribute->flags)->toBe(Attribute::TARGET_METHOD);
-});
-
-it('test class has correct attributes applied', function (): void {
- $reflection = new ReflectionClass(TestProduct::class);
-
- // Check class-level attributes
- $postTypeAttrs = $reflection->getAttributes(PostType::class);
- expect($postTypeAttrs)->toHaveCount(1);
-
- $hasArchiveAttrs = $reflection->getAttributes(HasArchive::class);
- expect($hasArchiveAttrs)->toHaveCount(1);
-
- $supportsAttrs = $reflection->getAttributes(Supports::class);
- expect($supportsAttrs)->toHaveCount(1);
-
- // Check method-level attributes
- $priceMethod = $reflection->getMethod('getPriceColumn');
- $priceAdminColAttrs = $priceMethod->getAttributes(AdminCol::class);
- expect($priceAdminColAttrs)->toHaveCount(1);
-
- $stockMethod = $reflection->getMethod('getStockColumn');
- $stockAdminColAttrs = $stockMethod->getAttributes(AdminCol::class);
- expect($stockAdminColAttrs)->toHaveCount(1);
-
- $metaBoxMethod = $reflection->getMethod('registerProductMetaBoxes');
- $metaBoxAttrs = $metaBoxMethod->getAttributes(RegisterMetaBoxCb::class);
- expect($metaBoxAttrs)->toHaveCount(1);
-});
-
-it('attribute values can be extracted correctly', function (): void {
- $reflection = new ReflectionClass(TestProduct::class);
-
- // Extract PostType attribute values
- $postTypeAttr = $reflection->getAttributes(PostType::class)[0];
- $postType = $postTypeAttr->newInstance();
- expect($postType->slug)->toBe('test-product');
- expect($postType->singular)->toBe('Product');
- expect($postType->plural)->toBe('Products');
-
- // Extract HasArchive attribute value
- $hasArchiveAttr = $reflection->getAttributes(HasArchive::class)[0];
- $hasArchive = $hasArchiveAttr->newInstance();
- expect($hasArchive->value)->toBeTrue();
-
- // Extract Supports attribute values
- $supportsAttr = $reflection->getAttributes(Supports::class)[0];
- $supports = $supportsAttr->newInstance();
- expect($supports->features)->toBe(['title', 'editor', 'thumbnail']);
-
- // Extract AdminCol attribute values
- $priceMethod = $reflection->getMethod('getPriceColumn');
- $adminColAttr = $priceMethod->getAttributes(AdminCol::class)[0];
- $adminCol = $adminColAttr->newInstance();
- expect($adminCol->key)->toBe('price');
- expect($adminCol->title)->toBe('Price');
-});
-
-it('accepts all types without validation', function (): void {
- // No validation should happen in attribute constructors
- // All validation is now handled by PostTypeDiscovery
-
- // PostType with any values
- expect(fn (): PostType => new PostType('', '', ''))->not->toThrow(Exception::class);
- expect(fn (): PostType => new PostType('invalid slug', 'invalid name', 'invalid plural'))->not->toThrow(Exception::class);
-
- // HasArchive with any values
- expect(fn (): HasArchive => new HasArchive(''))->not->toThrow(Exception::class);
- expect(fn (): HasArchive => new HasArchive('invalid-archive'))->not->toThrow(Exception::class);
-
- // Supports with any array
- expect(fn (): Supports => new Supports([]))->not->toThrow(Exception::class);
- expect(fn (): Supports => new Supports(['invalid-feature']))->not->toThrow(Exception::class);
-
- // MenuIcon with any string
- expect(fn (): MenuIcon => new MenuIcon(''))->not->toThrow(Exception::class);
- expect(fn (): MenuIcon => new MenuIcon('invalid-icon'))->not->toThrow(Exception::class);
-
- // AdminCol with any values
- expect(fn (): AdminCol => new AdminCol('', ''))->not->toThrow(Exception::class);
-});
-
-test('resolveSlug returns the explicit slug', function (): void {
- expect((new PostType('synthese-presse'))->resolveSlug('App\\Cms\\PostTypes\\SyntheseDePresse'))
- ->toBe('synthese-presse');
-});
-
-test('resolveSlug derives the slug from the class name, cut to 20 characters', function (): void {
- expect((new PostType)->resolveSlug('App\\Cms\\PostTypes\\SyntheseDePresse'))->toBe('synthese-de-presse')
- ->and((new PostType)->resolveSlug('App\\VeryLongPostTypeClassName'))->toBe('very-long-post-type-');
-});
diff --git a/tests/Unit/Attributes/PostTypeSubAttributesTest.php b/tests/Unit/Attributes/PostTypeSubAttributesTest.php
deleted file mode 100644
index ae2e589c..00000000
--- a/tests/Unit/Attributes/PostTypeSubAttributesTest.php
+++ /dev/null
@@ -1,271 +0,0 @@
-getAttributes(PostType::class);
- expect($postTypeAttrs)->toHaveCount(1);
-
- // Should detect HasArchive sub-attribute
- $hasArchiveAttrs = $reflection->getAttributes(HasArchive::class);
- expect($hasArchiveAttrs)->toHaveCount(1);
-
- // Should detect Supports sub-attribute
- $supportsAttrs = $reflection->getAttributes(Supports::class);
- expect($supportsAttrs)->toHaveCount(1);
-
- // Should detect MenuIcon sub-attribute
- $menuIconAttrs = $reflection->getAttributes(MenuIcon::class);
- expect($menuIconAttrs)->toHaveCount(1);
-});
-
-it('detects all method-level AdminCol attributes', function (): void {
- $reflection = new ReflectionClass(ProductWithSubAttributes::class);
-
- // Check price method
- $priceMethod = $reflection->getMethod('getPriceColumn');
- $priceAttrs = $priceMethod->getAttributes(AdminCol::class);
- expect($priceAttrs)->toHaveCount(1);
-
- // Check stock method
- $stockMethod = $reflection->getMethod('getStockColumn');
- $stockAttrs = $stockMethod->getAttributes(AdminCol::class);
- expect($stockAttrs)->toHaveCount(1);
-
- // Check category method
- $categoryMethod = $reflection->getMethod('getCategoryColumn');
- $categoryAttrs = $categoryMethod->getAttributes(AdminCol::class);
- expect($categoryAttrs)->toHaveCount(1);
-
- // Check that method without attribute has no AdminCol attributes
- $nameMethod = $reflection->getMethod('getProductName');
- $nameAttrs = $nameMethod->getAttributes(AdminCol::class);
- expect($nameAttrs)->toHaveCount(0);
-});
-
-it('extracts correct values from class-level attributes', function (): void {
- $reflection = new ReflectionClass(ProductWithSubAttributes::class);
-
- // Extract PostType attribute
- $postTypeAttr = $reflection->getAttributes(PostType::class)[0];
- $postType = $postTypeAttr->newInstance();
- expect($postType->slug)->toBe('product');
- expect($postType->singular)->toBeNull();
- expect($postType->plural)->toBeNull();
-
- // Extract HasArchive attribute
- $hasArchiveAttr = $reflection->getAttributes(HasArchive::class)[0];
- $hasArchive = $hasArchiveAttr->newInstance();
- expect($hasArchive->value)->toBe('products-archive');
-
- // Extract Supports attribute
- $supportsAttr = $reflection->getAttributes(Supports::class)[0];
- $supports = $supportsAttr->newInstance();
- expect($supports->features)->toBe(['title', 'editor', 'thumbnail', 'excerpt']);
-
- // Extract MenuIcon attribute
- $menuIconAttr = $reflection->getAttributes(MenuIcon::class)[0];
- $menuIcon = $menuIconAttr->newInstance();
- expect($menuIcon->value)->toBe('dashicons-cart');
-});
-
-it('extracts correct values from method-level AdminCol attributes', function (): void {
- $reflection = new ReflectionClass(ProductWithSubAttributes::class);
-
- // Extract price AdminCol
- $priceMethod = $reflection->getMethod('getPriceColumn');
- $priceAttr = $priceMethod->getAttributes(AdminCol::class)[0];
- $priceAdminCol = $priceAttr->newInstance();
- expect($priceAdminCol->key)->toBe('price');
- expect($priceAdminCol->title)->toBe('Price');
- expect($priceAdminCol->width)->toBeNull(); // Default value
- expect($priceAdminCol->sortable)->toBeFalse(); // Default value
-
- // Extract stock AdminCol
- $stockMethod = $reflection->getMethod('getStockColumn');
- $stockAttr = $stockMethod->getAttributes(AdminCol::class)[0];
- $stockAdminCol = $stockAttr->newInstance();
- expect($stockAdminCol->key)->toBe('stock');
- expect($stockAdminCol->title)->toBe('Stock Status');
- expect($stockAdminCol->width)->toBeNull(); // Default value
- expect($stockAdminCol->sortable)->toBeFalse(); // Default value
-
- // Extract category AdminCol
- $categoryMethod = $reflection->getMethod('getCategoryColumn');
- $categoryAttr = $categoryMethod->getAttributes(AdminCol::class)[0];
- $categoryAdminCol = $categoryAttr->newInstance();
- expect($categoryAdminCol->key)->toBe('category');
- expect($categoryAdminCol->title)->toBe('Category');
- expect($categoryAdminCol->width)->toBeNull(); // Default value
- expect($categoryAdminCol->sortable)->toBeFalse(); // Default value
-});
-
-it('supports multiple AdminCol attributes on different methods', function (): void {
- $reflection = new ReflectionClass(ProductWithSubAttributes::class);
- $adminColMethods = [];
-
- foreach ($reflection->getMethods() as $method) {
- $adminColAttrs = $method->getAttributes(AdminCol::class);
- if (! empty($adminColAttrs)) {
- $adminColMethods[$method->getName()] = $adminColAttrs[0]->newInstance();
- }
- }
-
- expect($adminColMethods)->toHaveCount(3);
- expect($adminColMethods)->toHaveKeys(['getPriceColumn', 'getStockColumn', 'getCategoryColumn']);
-
- // Verify each column has unique key
- $keys = array_map(fn (AdminCol $adminCol): string => $adminCol->key, $adminColMethods);
- expect($keys)->toBe(['getPriceColumn' => 'price', 'getStockColumn' => 'stock', 'getCategoryColumn' => 'category']);
- expect(array_unique($keys))->toHaveCount(3); // All keys are unique
-});
-
-it('demonstrates attribute composition pattern', function (): void {
- // This test demonstrates how the new system works:
- // 1. PostType attribute defines the main post type
- // 2. Sub-attributes (HasArchive, Supports, MenuIcon) configure post type settings
- // 3. Method-level attributes (AdminCol) define callbacks and additional functionality
-
- $reflection = new ReflectionClass(ProductWithSubAttributes::class);
-
- // Main post type definition
- $postTypeAttr = $reflection->getAttributes(PostType::class)[0]->newInstance();
- expect($postTypeAttr->slug)->toBe('product');
-
- // Archive configuration
- $hasArchiveAttr = $reflection->getAttributes(HasArchive::class)[0]->newInstance();
- expect($hasArchiveAttr->value)->toBe('products-archive');
-
- // Features configuration
- $supportsAttr = $reflection->getAttributes(Supports::class)[0]->newInstance();
- expect($supportsAttr->features)->toContain('title', 'editor', 'thumbnail', 'excerpt');
-
- // Admin UI configuration
- $menuIconAttr = $reflection->getAttributes(MenuIcon::class)[0]->newInstance();
- expect($menuIconAttr->value)->toBe('dashicons-cart');
-
- // Admin columns configuration (method-level)
- $columnMethods = [];
- foreach ($reflection->getMethods() as $method) {
- $adminColAttrs = $method->getAttributes(AdminCol::class);
- if (! empty($adminColAttrs)) {
- $columnMethods[] = [
- 'method' => $method->getName(),
- 'column' => $adminColAttrs[0]->newInstance(),
- ];
- }
- }
-
- expect($columnMethods)->toHaveCount(3);
-
- // Verify the composition creates a complete post type configuration
- $allClassAttributes = $reflection->getAttributes();
- $allMethodAttributes = [];
- foreach ($reflection->getMethods() as $method) {
- $allMethodAttributes = array_merge($allMethodAttributes, $method->getAttributes());
- }
-
- // Should have class-level attributes for configuration
- expect($allClassAttributes)->toHaveCount(4); // PostType + 3 sub-attributes
-
- // Should have method-level attributes for callbacks
- expect($allMethodAttributes)->toHaveCount(3); // 3 AdminCol attributes
-});
-
-it('attributes have correct target configurations', function (): void {
- // Verify class-level attributes target classes
- $classLevelAttributes = [
- PostType::class,
- HasArchive::class,
- Supports::class,
- MenuIcon::class,
- ];
-
- foreach ($classLevelAttributes as $attributeClass) {
- $reflection = new ReflectionClass($attributeClass);
- $attributes = $reflection->getAttributes(Attribute::class);
- expect($attributes)->toHaveCount(1);
-
- $attribute = $attributes[0]->newInstance();
- expect($attribute->flags)->toBe(Attribute::TARGET_CLASS);
- }
-
- // Verify method-level attributes target methods
- $methodLevelAttributes = [
- AdminCol::class,
- ];
-
- foreach ($methodLevelAttributes as $attributeClass) {
- $reflection = new ReflectionClass($attributeClass);
- $attributes = $reflection->getAttributes(Attribute::class);
- expect($attributes)->toHaveCount(1);
-
- $attribute = $attributes[0]->newInstance();
- expect($attribute->flags)->toBe(Attribute::TARGET_METHOD);
- }
-});
-
-it('demonstrates no validation in attributes', function (): void {
- // All attributes should accept any values without validation
- // Validation will be handled by PostTypeDiscovery
-
- expect(fn (): PostType => new PostType('', '', ''))->not->toThrow(Exception::class);
- expect(fn (): HasArchive => new HasArchive(''))->not->toThrow(Exception::class);
- expect(fn (): Supports => new Supports([]))->not->toThrow(Exception::class);
- expect(fn (): MenuIcon => new MenuIcon(''))->not->toThrow(Exception::class);
- expect(fn (): AdminCol => new AdminCol('', ''))->not->toThrow(Exception::class);
-
- // Even completely invalid values should not throw
- expect(fn (): PostType => new PostType('invalid slug with spaces', 'inv@lid', 'pl{ur}al'))->not->toThrow(Exception::class);
- expect(fn (): HasArchive => new HasArchive(123))->not->toThrow(Exception::class); // Wrong type
- expect(fn (): Supports => new Supports(['invalid-feature', '', null]))->not->toThrow(Exception::class);
- expect(fn (): MenuIcon => new MenuIcon(null))->not->toThrow(Exception::class); // Wrong type
-});
diff --git a/tests/Unit/Attributes/ScheduleTest.php b/tests/Unit/Attributes/ScheduleTest.php
deleted file mode 100644
index 97843335..00000000
--- a/tests/Unit/Attributes/ScheduleTest.php
+++ /dev/null
@@ -1,158 +0,0 @@
-toBeInstanceOf(Schedule::class);
- expect($schedule->recurrence)->toBe('daily');
- expect($schedule->hook)->toBeNull();
- expect($schedule->args)->toBe([]);
-});
-
-it('creates Schedule attribute with custom hook name', function (): void {
- $schedule = new Schedule('hourly', 'custom_hook_name');
-
- expect($schedule->recurrence)->toBe('hourly');
- expect($schedule->hook)->toBe('custom_hook_name');
- expect($schedule->args)->toBe([]);
-});
-
-it('creates Schedule attribute with arguments', function (): void {
- $args = ['type' => 'full', 'force' => true];
- $schedule = new Schedule('daily', null, $args);
-
- expect($schedule->recurrence)->toBe('daily');
- expect($schedule->hook)->toBeNull();
- expect($schedule->args)->toBe($args);
-});
-
-it('creates Schedule attribute with all parameters', function (): void {
- $args = ['batch_size' => 100];
- $schedule = new Schedule('weekly', 'weekly_cleanup', $args);
-
- expect($schedule->recurrence)->toBe('weekly');
- expect($schedule->hook)->toBe('weekly_cleanup');
- expect($schedule->args)->toBe($args);
-});
-
-it('creates Schedule attribute with array recurrence', function (): void {
- $recurrence = ['interval' => 3600, 'display' => 'Every Hour'];
- $schedule = new Schedule($recurrence);
-
- expect($schedule->recurrence)->toBe($recurrence);
- expect($schedule->hook)->toBeNull();
- expect($schedule->args)->toBe([]);
-});
-
-it('creates Schedule attribute with Every enum', function (): void {
- $schedule = new Schedule(Every::DAY);
-
- expect($schedule->recurrence)->toBe(Every::DAY);
- expect($schedule->hook)->toBeNull();
- expect($schedule->args)->toBe([]);
-});
-
-it('creates Schedule attribute with Interval instance', function (): void {
- $interval = new Interval(minutes: 30, hours: 2);
- $schedule = new Schedule($interval);
-
- expect($schedule->recurrence)->toBe($interval);
- expect($schedule->hook)->toBeNull();
- expect($schedule->args)->toBe([]);
-});
-
-it('creates Schedule attribute with complex Every enum and parameters', function (): void {
- $args = ['source' => 'api', 'limit' => 50];
- $schedule = new Schedule(Every::MONTH, 'monthly_sync', $args);
-
- expect($schedule->recurrence)->toBe(Every::MONTH);
- expect($schedule->hook)->toBe('monthly_sync');
- expect($schedule->args)->toBe($args);
-});
-
-it('creates Schedule attribute with complex Interval and parameters', function (): void {
- $interval = new Interval(minutes: 30, hours: 12, days: 1);
- $args = ['cleanup_type' => 'deep'];
- $schedule = new Schedule($interval, 'complex_cleanup', $args);
-
- expect($schedule->recurrence)->toBe($interval);
- expect($schedule->hook)->toBe('complex_cleanup');
- expect($schedule->args)->toBe($args);
-});
-
-it('stores recurrence as readonly property', function (): void {
- $schedule = new Schedule('daily');
-
- expect($schedule->recurrence)->toBe('daily');
-
- // Verify property is readonly by checking it cannot be modified
- $reflection = new ReflectionClass($schedule);
- $property = $reflection->getProperty('recurrence');
- expect($property->isReadOnly())->toBeTrue();
-});
-
-it('stores hook as readonly property', function (): void {
- $schedule = new Schedule('daily', 'test_hook');
-
- expect($schedule->hook)->toBe('test_hook');
-
- // Verify property is readonly
- $reflection = new ReflectionClass($schedule);
- $property = $reflection->getProperty('hook');
- expect($property->isReadOnly())->toBeTrue();
-});
-
-it('stores args as readonly property', function (): void {
- $args = ['key' => 'value'];
- $schedule = new Schedule('daily', null, $args);
-
- expect($schedule->args)->toBe($args);
-
- // Verify property is readonly
- $reflection = new ReflectionClass($schedule);
- $property = $reflection->getProperty('args');
- expect($property->isReadOnly())->toBeTrue();
-});
-
-it('has correct PHP attribute configuration', function (): void {
- $reflection = new ReflectionClass(Schedule::class);
- $attributes = $reflection->getAttributes(Attribute::class);
-
- expect($attributes)->toHaveCount(1);
-
- $attribute = $attributes[0]->newInstance();
- expect($attribute->flags)->toBe(Attribute::TARGET_METHOD);
-});
-
-it('accepts all supported recurrence types without validation', function (): void {
- // No validation should happen in the attribute constructor
- // All validation is now handled by ScheduleDiscovery
-
- // String recurrence
- expect(fn (): Schedule => new Schedule('daily'))->not->toThrow(Exception::class);
- expect(fn (): Schedule => new Schedule('invalid_schedule'))->not->toThrow(Exception::class); // No validation
-
- // Array recurrence
- expect(fn (): Schedule => new Schedule(['interval' => 3600, 'display' => 'Valid']))->not->toThrow(Exception::class);
- expect(fn (): Schedule => new Schedule(['invalid' => 'array']))->not->toThrow(Exception::class); // No validation
-
- // Every enum
- expect(fn (): Schedule => new Schedule(Every::HOUR))->not->toThrow(Exception::class);
- expect(fn (): Schedule => new Schedule(Every::MONTH))->not->toThrow(Exception::class);
-
- // Interval instance
- $interval = new Interval(minutes: 30);
- expect(fn (): Schedule => new Schedule($interval))->not->toThrow(Exception::class);
-});
diff --git a/tests/Unit/Attributes/TaxonomyAttributeTest.php b/tests/Unit/Attributes/TaxonomyAttributeTest.php
index 66edb2fb..52a346d5 100644
--- a/tests/Unit/Attributes/TaxonomyAttributeTest.php
+++ b/tests/Unit/Attributes/TaxonomyAttributeTest.php
@@ -5,6 +5,7 @@
use Illuminate\Container\Container;
use Illuminate\Support\Facades\Facade;
use Mockery as m;
+use Pollora\Attributes\Contracts\ConfiguresArguments;
use Pollora\Attributes\Taxonomy;
use Pollora\Attributes\Taxonomy\AllowHierarchy;
use Pollora\Attributes\Taxonomy\Args;
@@ -161,8 +162,8 @@ function testBooleanAttribute(string $attributeName, string $argName): void
$reflectionClass = new ReflectionClass($taxonomy);
foreach ($reflectionClass->getAttributes() as $attribute) {
$attributeInstance = $attribute->newInstance();
- if (method_exists($attributeInstance, 'handle')) {
- $attributeInstance->handle(app(), $taxonomy, $reflectionClass, $attributeInstance);
+ if ($attributeInstance instanceof ConfiguresArguments) {
+ $taxonomy->attributeArgs = $attributeInstance->configureArguments($taxonomy->attributeArgs);
}
}
@@ -180,8 +181,8 @@ function testValueAttribute(string $attributeName, string $argName, mixed $expec
$reflectionClass = new ReflectionClass($taxonomy);
foreach ($reflectionClass->getAttributes() as $attribute) {
$attributeInstance = $attribute->newInstance();
- if (method_exists($attributeInstance, 'handle')) {
- $attributeInstance->handle(app(), $taxonomy, $reflectionClass, $attributeInstance);
+ if ($attributeInstance instanceof ConfiguresArguments) {
+ $taxonomy->attributeArgs = $attributeInstance->configureArguments($taxonomy->attributeArgs);
}
}
@@ -206,7 +207,7 @@ function testMethodAttribute(string $attributeName, string $argName, string $met
$attributes = $method->getAttributes($attributeClass);
foreach ($attributes as $attribute) {
$attributeInstance = $attribute->newInstance();
- $attributeInstance->handle(null, $taxonomy, $method, $attributeInstance);
+ $taxonomy->attributeArgs = $attributeInstance->configureArguments($taxonomy->attributeArgs, [$taxonomy, $method->getName()]);
}
}
@@ -259,8 +260,8 @@ function testMethodAttribute(string $attributeName, string $argName, string $met
$reflectionClass = new ReflectionClass($taxonomy);
foreach ($reflectionClass->getAttributes() as $attribute) {
$attributeInstance = $attribute->newInstance();
- if (method_exists($attributeInstance, 'handle')) {
- $attributeInstance->handle(app(), $taxonomy, $reflectionClass, $attributeInstance);
+ if ($attributeInstance instanceof ConfiguresArguments) {
+ $taxonomy->attributeArgs = $attributeInstance->configureArguments($taxonomy->attributeArgs);
}
}
diff --git a/tests/Unit/Attributes/TaxonomySubAttributesTest.php b/tests/Unit/Attributes/TaxonomySubAttributesTest.php
deleted file mode 100644
index 3838bb40..00000000
--- a/tests/Unit/Attributes/TaxonomySubAttributesTest.php
+++ /dev/null
@@ -1,313 +0,0 @@
-Custom taxonomy meta box
';
- }
-
- #[UpdateCountCallback]
- public function updateTermCount($terms, $taxonomy): void
- {
- // Custom count update logic
- foreach ($terms as $term) {
- // Update count logic here
- }
- }
-
- #[MetaBoxSanitizeCb]
- public function sanitizeTerms($terms): array
- {
- // Sanitize the terms
- return array_map(sanitize_text_field(...), (array) $terms);
- }
-
- public function getTaxonomyName(): string
- {
- return 'Product Category';
- }
-}
-
-it('detects all class-level Taxonomy attributes', function (): void {
- $reflection = new ReflectionClass(ProductCategoryWithSubAttributes::class);
-
- // Should detect Taxonomy main attribute
- $taxonomyAttrs = $reflection->getAttributes(Taxonomy::class);
- expect($taxonomyAttrs)->toHaveCount(1);
-
- // Should detect Hierarchical sub-attribute
- $hierarchicalAttrs = $reflection->getAttributes(Hierarchical::class);
- expect($hierarchicalAttrs)->toHaveCount(1);
-
- // Should detect PublicTaxonomy sub-attribute
- $publicAttrs = $reflection->getAttributes(PublicTaxonomy::class);
- expect($publicAttrs)->toHaveCount(1);
-
- // Should detect ShowUI sub-attribute
- $showUIAttrs = $reflection->getAttributes(ShowUI::class);
- expect($showUIAttrs)->toHaveCount(1);
-
- // Should detect ShowInRest sub-attribute
- $showInRestAttrs = $reflection->getAttributes(ShowInRest::class);
- expect($showInRestAttrs)->toHaveCount(1);
-
- // Should detect ObjectType sub-attribute
- $objectTypeAttrs = $reflection->getAttributes(ObjectType::class);
- expect($objectTypeAttrs)->toHaveCount(1);
-});
-
-it('detects all method-level callback attributes', function (): void {
- $reflection = new ReflectionClass(ProductCategoryWithSubAttributes::class);
-
- // Check custom meta box method
- $metaBoxMethod = $reflection->getMethod('customMetaBox');
- $metaBoxAttrs = $metaBoxMethod->getAttributes(MetaBoxCb::class);
- expect($metaBoxAttrs)->toHaveCount(1);
-
- // Check update count method
- $updateCountMethod = $reflection->getMethod('updateTermCount');
- $updateCountAttrs = $updateCountMethod->getAttributes(UpdateCountCallback::class);
- expect($updateCountAttrs)->toHaveCount(1);
-
- // Check sanitize method
- $sanitizeMethod = $reflection->getMethod('sanitizeTerms');
- $sanitizeAttrs = $sanitizeMethod->getAttributes(MetaBoxSanitizeCb::class);
- expect($sanitizeAttrs)->toHaveCount(1);
-
- // Check that method without attribute has no callback attributes
- $nameMethod = $reflection->getMethod('getTaxonomyName');
- $nameCallbackAttrs = array_merge(
- $nameMethod->getAttributes(MetaBoxCb::class),
- $nameMethod->getAttributes(UpdateCountCallback::class),
- $nameMethod->getAttributes(MetaBoxSanitizeCb::class)
- );
- expect($nameCallbackAttrs)->toHaveCount(0);
-});
-
-it('extracts correct values from class-level attributes', function (): void {
- $reflection = new ReflectionClass(ProductCategoryWithSubAttributes::class);
-
- // Extract Taxonomy attribute
- $taxonomyAttr = $reflection->getAttributes(Taxonomy::class)[0];
- $taxonomy = $taxonomyAttr->newInstance();
- expect($taxonomy->slug)->toBe('product-category');
- expect($taxonomy->singular)->toBeNull();
- expect($taxonomy->plural)->toBeNull();
- expect($taxonomy->objectType)->toBeNull();
-
- // Test that attributes can be instantiated successfully
- $hierarchicalAttr = $reflection->getAttributes(Hierarchical::class)[0];
- $hierarchical = $hierarchicalAttr->newInstance();
- expect($hierarchical)->toBeInstanceOf(Hierarchical::class);
-
- $publicAttr = $reflection->getAttributes(PublicTaxonomy::class)[0];
- $public = $publicAttr->newInstance();
- expect($public)->toBeInstanceOf(PublicTaxonomy::class);
-
- $showUIAttr = $reflection->getAttributes(ShowUI::class)[0];
- $showUI = $showUIAttr->newInstance();
- expect($showUI)->toBeInstanceOf(ShowUI::class);
-
- $showInRestAttr = $reflection->getAttributes(ShowInRest::class)[0];
- $showInRest = $showInRestAttr->newInstance();
- expect($showInRest)->toBeInstanceOf(ShowInRest::class);
-
- $objectTypeAttr = $reflection->getAttributes(ObjectType::class)[0];
- $objectType = $objectTypeAttr->newInstance();
- expect($objectType)->toBeInstanceOf(ObjectType::class);
-});
-
-it('extracts correct values from method-level callback attributes', function (): void {
- $reflection = new ReflectionClass(ProductCategoryWithSubAttributes::class);
-
- // Extract MetaBoxCb
- $metaBoxMethod = $reflection->getMethod('customMetaBox');
- $metaBoxAttr = $metaBoxMethod->getAttributes(MetaBoxCb::class)[0];
- $metaBoxCb = $metaBoxAttr->newInstance();
- expect($metaBoxCb)->toBeInstanceOf(MetaBoxCb::class);
-
- // Extract UpdateCountCallback
- $updateCountMethod = $reflection->getMethod('updateTermCount');
- $updateCountAttr = $updateCountMethod->getAttributes(UpdateCountCallback::class)[0];
- $updateCountCb = $updateCountAttr->newInstance();
- expect($updateCountCb)->toBeInstanceOf(UpdateCountCallback::class);
-
- // Extract MetaBoxSanitizeCb
- $sanitizeMethod = $reflection->getMethod('sanitizeTerms');
- $sanitizeAttr = $sanitizeMethod->getAttributes(MetaBoxSanitizeCb::class)[0];
- $sanitizeCb = $sanitizeAttr->newInstance();
- expect($sanitizeCb)->toBeInstanceOf(MetaBoxSanitizeCb::class);
-});
-
-it('supports multiple callback attributes on different methods', function (): void {
- $reflection = new ReflectionClass(ProductCategoryWithSubAttributes::class);
- $callbackMethods = [];
-
- foreach ($reflection->getMethods() as $method) {
- $callbackAttrs = array_merge(
- $method->getAttributes(MetaBoxCb::class),
- $method->getAttributes(UpdateCountCallback::class),
- $method->getAttributes(MetaBoxSanitizeCb::class)
- );
-
- if ($callbackAttrs !== []) {
- $callbackMethods[$method->getName()] = $callbackAttrs[0]->newInstance();
- }
- }
-
- expect($callbackMethods)->toHaveCount(3);
- expect($callbackMethods)->toHaveKeys(['customMetaBox', 'updateTermCount', 'sanitizeTerms']);
-
- // Verify each callback has correct type
- expect($callbackMethods['customMetaBox'])->toBeInstanceOf(MetaBoxCb::class);
- expect($callbackMethods['updateTermCount'])->toBeInstanceOf(UpdateCountCallback::class);
- expect($callbackMethods['sanitizeTerms'])->toBeInstanceOf(MetaBoxSanitizeCb::class);
-});
-
-it('demonstrates attribute composition pattern for taxonomies', function (): void {
- // This test demonstrates how the taxonomy attribute system works:
- // 1. Taxonomy attribute defines the main taxonomy
- // 2. Sub-attributes (Hierarchical, PublicTaxonomy, ShowUI, etc.) configure taxonomy settings
- // 3. Method-level attributes (MetaBoxCb, UpdateCountCallback, etc.) define callbacks
-
- $reflection = new ReflectionClass(ProductCategoryWithSubAttributes::class);
-
- // Main taxonomy definition
- $taxonomyAttr = $reflection->getAttributes(Taxonomy::class)[0]->newInstance();
- expect($taxonomyAttr->slug)->toBe('product-category');
-
- // Test attribute instantiation
- $hierarchicalAttr = $reflection->getAttributes(Hierarchical::class)[0]->newInstance();
- expect($hierarchicalAttr)->toBeInstanceOf(Hierarchical::class);
-
- $publicAttr = $reflection->getAttributes(PublicTaxonomy::class)[0]->newInstance();
- expect($publicAttr)->toBeInstanceOf(PublicTaxonomy::class);
-
- $showUIAttr = $reflection->getAttributes(ShowUI::class)[0]->newInstance();
- expect($showUIAttr)->toBeInstanceOf(ShowUI::class);
-
- $showInRestAttr = $reflection->getAttributes(ShowInRest::class)[0]->newInstance();
- expect($showInRestAttr)->toBeInstanceOf(ShowInRest::class);
-
- $objectTypeAttr = $reflection->getAttributes(ObjectType::class)[0]->newInstance();
- expect($objectTypeAttr)->toBeInstanceOf(ObjectType::class);
-
- // Callback methods configuration (method-level)
- $callbackMethods = [];
- foreach ($reflection->getMethods() as $method) {
- $callbackAttrs = array_merge(
- $method->getAttributes(MetaBoxCb::class),
- $method->getAttributes(UpdateCountCallback::class),
- $method->getAttributes(MetaBoxSanitizeCb::class)
- );
-
- if ($callbackAttrs !== []) {
- $callbackMethods[] = [
- 'method' => $method->getName(),
- 'attribute' => $callbackAttrs[0]->newInstance(),
- ];
- }
- }
-
- expect($callbackMethods)->toHaveCount(3);
-
- // Verify the composition creates a complete taxonomy configuration
- $allClassAttributes = $reflection->getAttributes();
- $allMethodAttributes = [];
- foreach ($reflection->getMethods() as $method) {
- $allMethodAttributes = array_merge($allMethodAttributes, $method->getAttributes());
- }
-
- // Should have class-level attributes for configuration
- expect($allClassAttributes)->toHaveCount(6); // Taxonomy + 5 sub-attributes
-
- // Should have method-level attributes for callbacks
- expect($allMethodAttributes)->toHaveCount(3); // 3 callback attributes
-});
-
-it('attributes have correct target configurations', function (): void {
- // Verify class-level attributes target classes
- $classLevelAttributes = [
- Taxonomy::class,
- Hierarchical::class,
- PublicTaxonomy::class,
- ShowUI::class,
- ShowInRest::class,
- ObjectType::class,
- ];
-
- foreach ($classLevelAttributes as $attributeClass) {
- $reflection = new ReflectionClass($attributeClass);
- $attributes = $reflection->getAttributes(Attribute::class);
- expect($attributes)->toHaveCount(1);
-
- $attribute = $attributes[0]->newInstance();
- expect($attribute->flags)->toBe(Attribute::TARGET_CLASS);
- }
-
- // Verify method-level attributes target methods
- $methodLevelAttributes = [
- MetaBoxCb::class,
- UpdateCountCallback::class,
- MetaBoxSanitizeCb::class,
- ];
-
- foreach ($methodLevelAttributes as $attributeClass) {
- $reflection = new ReflectionClass($attributeClass);
- $attributes = $reflection->getAttributes(Attribute::class);
- expect($attributes)->toHaveCount(1);
-
- $attribute = $attributes[0]->newInstance();
- expect($attribute->flags)->toBe(Attribute::TARGET_METHOD);
- }
-});
-
-it('demonstrates no validation in attributes', function (): void {
- // All attributes should accept any values without validation
- // Validation will be handled by TaxonomyDiscovery
-
- expect(fn (): Taxonomy => new Taxonomy('', '', '', []))->not->toThrow(Throwable::class);
- expect(fn (): Hierarchical => new Hierarchical(false))->not->toThrow(Throwable::class);
- expect(fn (): PublicTaxonomy => new PublicTaxonomy(false))->not->toThrow(Throwable::class);
- expect(fn (): ShowUI => new ShowUI(false))->not->toThrow(Throwable::class);
- expect(fn (): ShowInRest => new ShowInRest(false))->not->toThrow(Throwable::class);
- expect(fn (): ObjectType => new ObjectType([]))->not->toThrow(Throwable::class);
- expect(fn (): MetaBoxCb => new MetaBoxCb)->not->toThrow(Throwable::class);
- expect(fn (): UpdateCountCallback => new UpdateCountCallback)->not->toThrow(Throwable::class);
- expect(fn (): MetaBoxSanitizeCb => new MetaBoxSanitizeCb)->not->toThrow(Throwable::class);
-
- // Even completely invalid values should not throw
- expect(fn (): Taxonomy => new Taxonomy('invalid slug with spaces', 'inv@lid', 'pl{ur}al', 'invalid'))->not->toThrow(Throwable::class);
- expect(fn (): Hierarchical => new Hierarchical('not-boolean'))->not->toThrow(Throwable::class); // Wrong type
- expect(fn (): ObjectType => new ObjectType('not-array'))->not->toThrow(Throwable::class); // Wrong type
-});
diff --git a/tests/Unit/Discovery/Domain/Models/DiscoveryItemsTest.php b/tests/Unit/Discovery/Domain/Models/DiscoveryItemsTest.php
deleted file mode 100644
index eebaf84c..00000000
--- a/tests/Unit/Discovery/Domain/Models/DiscoveryItemsTest.php
+++ /dev/null
@@ -1,154 +0,0 @@
-isLoaded())->toBeFalse();
- expect($items)->toHaveCount(0);
- expect($items->all())->toBe([]);
- });
-
- it('can create with initial data', function (): void {
- $items = new DiscoveryItems([
- 'location1' => ['item1', 'item2'],
- 'location2' => ['item3'],
- ]);
-
- expect($items->isLoaded())->toBeTrue();
- expect($items)->toHaveCount(3);
- expect($items->all())->toEqual(['item1', 'item2', 'item3']);
- });
-
- it('can add single item for location', function (): void {
- $items = new DiscoveryItems;
- $location = new DiscoveryLocation('App\\Models', '/app/models');
-
- $items->add($location, 'test-item');
-
- expect($items->hasLocation($location))->toBeTrue();
- expect($items->getForLocation($location))->toEqual(['test-item']);
- expect($items)->toHaveCount(1);
- });
-
- it('can add multiple items for location', function (): void {
- $items = new DiscoveryItems;
- $location = new DiscoveryLocation('App\\Models', '/app/models');
-
- $items->addForLocation($location, ['item1', 'item2', 'item3']);
-
- expect($items->getForLocation($location))->toEqual(['item1', 'item2', 'item3']);
- expect($items)->toHaveCount(3);
- });
-
- it('can add items to existing location', function (): void {
- $items = new DiscoveryItems;
- $location = new DiscoveryLocation('App\\Models', '/app/models');
-
- $items->add($location, 'item1');
- $items->addForLocation($location, ['item2', 'item3']);
-
- expect($items->getForLocation($location))->toEqual(['item1', 'item2', 'item3']);
- expect($items)->toHaveCount(3);
- });
-
- it('handles multiple locations', function (): void {
- $items = new DiscoveryItems;
- $location1 = new DiscoveryLocation('App\\Models', '/app/models');
- $location2 = new DiscoveryLocation('App\\Services', '/app/services');
-
- $items->addForLocation($location1, ['model1', 'model2']);
- $items->addForLocation($location2, ['service1']);
-
- expect($items)->toHaveCount(3);
- expect($items->getForLocation($location1))->toEqual(['model1', 'model2']);
- expect($items->getForLocation($location2))->toEqual(['service1']);
- expect($items->all())->toEqual(['model1', 'model2', 'service1']);
- });
-
- it('returns empty array for unknown location', function (): void {
- $items = new DiscoveryItems;
- $location = new DiscoveryLocation('App\\Models', '/app/models');
-
- expect($items->getForLocation($location))->toBe([]);
- expect($items->hasLocation($location))->toBeFalse();
- });
-
- it('can iterate over all items', function (): void {
- $items = new DiscoveryItems;
- $location1 = new DiscoveryLocation('App\\Models', '/app/models');
- $location2 = new DiscoveryLocation('App\\Services', '/app/services');
-
- $items->addForLocation($location1, ['model1', 'model2']);
- $items->addForLocation($location2, ['service1']);
-
- $iteratedItems = [];
- foreach ($items as $item) {
- $iteratedItems[] = $item;
- }
-
- expect($iteratedItems)->toEqual(['model1', 'model2', 'service1']);
- });
-
- it('supports serialization and unserialization', function (): void {
- $items = new DiscoveryItems;
- $location = new DiscoveryLocation('App\\Models', '/app/models');
-
- $items->addForLocation($location, ['item1', 'item2']);
-
- $serialized = $items->__serialize();
- expect($serialized)->toBeArray();
-
- $newItems = new DiscoveryItems;
- $newItems->__unserialize($serialized);
-
- expect($newItems->all())->toEqual($items->all());
- expect($newItems->count())->toBe($items->count());
- });
-
- it('onlyVendor returns new instance', function (): void {
- $items = new DiscoveryItems([
- 'location1' => ['item1'],
- 'location2' => ['item2'],
- ]);
-
- $vendorItems = $items->onlyVendor();
-
- expect($vendorItems)->not->toBe($items);
- expect($vendorItems)->toBeInstanceOf(DiscoveryItems::class);
- });
-});
-
-describe('DiscoveryItems pending items', function (): void {
- it('has every item pending until marked applied', function (): void {
- $items = new DiscoveryItems;
- $items->addForLocation(new DiscoveryLocation('App\\Models', '/app/models'), ['item1', 'item2']);
-
- expect($items->pending()->all())->toBe(['item1', 'item2']);
-
- $items->markApplied();
-
- expect($items->pending()->isLoaded())->toBeFalse()
- ->and($items->all())->toBe(['item1', 'item2']);
- });
-
- it('keeps as pending only the items added since, in every location', function (): void {
- $models = new DiscoveryLocation('App\\Models', '/app/models');
- $theme = new DiscoveryLocation('Theme\\Demo', '/themes/demo');
- $items = new DiscoveryItems;
- $items->add($models, 'model1');
- $items->markApplied();
-
- $items->add($models, 'model2');
- $items->add($theme, 'theme1');
-
- expect($items->pending()->all())->toBe(['model2', 'theme1'])
- ->and($items->pending())->not->toBe($items)
- ->and($items->all())->toBe(['model1', 'model2', 'theme1']);
- });
-});
diff --git a/tests/Unit/Discovery/Domain/Models/DiscoveryLocationTest.php b/tests/Unit/Discovery/Domain/Models/DiscoveryLocationTest.php
deleted file mode 100644
index b821be06..00000000
--- a/tests/Unit/Discovery/Domain/Models/DiscoveryLocationTest.php
+++ /dev/null
@@ -1,67 +0,0 @@
-getNamespace())->toBe('App\\Models');
- expect($location->getPath())->toBe('/path/to/models');
- });
-
- it('generates unique key based on path', function (): void {
- $location1 = new DiscoveryLocation('App\\Models', '/path/to/models');
- $location2 = new DiscoveryLocation('App\\Services', '/path/to/models');
- $location3 = new DiscoveryLocation('App\\Models', '/path/to/services');
-
- expect($location1->getKey())->toBe($location2->getKey());
- expect($location1->getKey())->not->toBe($location3->getKey());
- });
-
- it('detects vendor locations', function (): void {
- $vendorLocation = new DiscoveryLocation('Vendor\\Package', '/path/to/vendor/package');
- $appLocation = new DiscoveryLocation('App\\Models', '/path/to/app/models');
-
- expect($vendorLocation->isVendor())->toBeTrue();
- expect($appLocation->isVendor())->toBeFalse();
- });
-
- it('detects vendor locations with windows path', function (): void {
- $vendorLocation = new DiscoveryLocation('Vendor\\Package', 'C:\\path\\to\\vendor\\package');
-
- expect($vendorLocation->isVendor())->toBeTrue();
- });
-
- it('converts file path to class name', function (): void {
- $location = new DiscoveryLocation('App\\Models', '/app/Models');
-
- expect($location->toClassName('/app/Models/User.php'))->toBe('App\\Models\\User');
- });
-
- it('converts nested file path to class name', function (): void {
- $location = new DiscoveryLocation('App\\Services', '/app/Services');
-
- expect($location->toClassName('/app/Services/Auth/UserService.php'))->toBe('App\\Services\\Auth\\UserService');
- });
-
- it('returns empty string for file outside location', function (): void {
- $location = new DiscoveryLocation('App\\Models', '/app/Models');
-
- expect($location->toClassName('/app/Services/UserService.php'))->toBe('');
- });
-
- it('handles windows paths in class name conversion', function (): void {
- $location = new DiscoveryLocation('App\\Models', 'C:\\app\\Models');
-
- expect($location->toClassName('C:\\app\\Models\\Auth\\User.php'))->toBe('App\\Models\\Auth\\User');
- });
-
- it('trims namespace slashes', function (): void {
- $location = new DiscoveryLocation('App\\Models\\', '/app/Models');
-
- expect($location->toClassName('/app/Models/User.php'))->toBe('App\\Models\\User');
- });
-});
diff --git a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php
index 1266b4ed..271605d8 100644
--- a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php
+++ b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php
@@ -7,6 +7,7 @@
use Pollora\Application\Domain\Contracts\DebugDetectorInterface;
use Pollora\Discovery\Domain\Contracts\DiscoveryLocationInterface;
use Pollora\Discovery\Domain\Models\DiscoveryContext;
+use Pollora\Discovery\Infrastructure\Adapters\LaravelDiscoveryCacheManagerFactory;
use Pollora\Discovery\Infrastructure\Services\DiscoveryCacheManager;
use Pollora\Discovery\Infrastructure\Services\ReflectionCache;
use Psr\Log\LoggerInterface;
@@ -38,15 +39,14 @@ function managerFor(bool $debug, ?LoggerInterface $logger = null): DiscoveryCach
$container->instance(LoggerInterface::class, $logger);
}
- // config() reaches for the application container; without it the
- // non-debug path cannot resolve a cache driver.
+ // Without a config repository the non-debug path cannot resolve a cache driver.
$container->instance('config', new Repository(['structure-discoverer' => ['cache' => []]]));
Container::setInstance($container);
$detector = Mockery::mock(DebugDetectorInterface::class);
$detector->shouldReceive('isDebugMode')->andReturn($debug);
- return new DiscoveryCacheManager($container, $detector);
+ return (new LaravelDiscoveryCacheManagerFactory($container, $detector))->make();
}
function scanOf(DiscoveryCacheManager $manager, string $path): void
@@ -157,14 +157,14 @@ function scanOf(DiscoveryCacheManager $manager, string $path): void
$detector = Mockery::mock(DebugDetectorInterface::class);
$detector->shouldReceive('isDebugMode')->andReturn(false);
- $first = new DiscoveryCacheManager($container, $detector);
+ $first = (new LaravelDiscoveryCacheManagerFactory($container, $detector))->make();
scanOf($first, $this->dir);
// A new process starts with an empty copy of its own, and only the
// persistent cache is left to answer.
(new ReflectionProperty(DiscoveryCacheManager::class, 'structuresCache'))->setValue(null, []);
- $second = new DiscoveryCacheManager($container, $detector);
+ $second = (new LaravelDiscoveryCacheManagerFactory($container, $detector))->make();
scanOf($second, $this->dir);
expect($first->scans()[0]['source'])->toBe('disk')
diff --git a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryEngineTest.php b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryEngineTest.php
deleted file mode 100644
index 9987ab03..00000000
--- a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryEngineTest.php
+++ /dev/null
@@ -1,342 +0,0 @@
- true]);
-
- return new DiscoveryEngine(
- $container,
- $debugDetector,
- cacheManager: $cacheManager ?? Mockery::mock(DiscoveryCacheManager::class)
- );
-}
-
-function createLocationMock(string $path): DiscoveryLocationInterface
-{
- $location = Mockery::mock(DiscoveryLocationInterface::class);
- $location->shouldReceive('getPath')->andReturn($path);
- $location->shouldReceive('getKey')->andReturn('key_'.md5($path));
- $location->shouldReceive('isVendor')->andReturn(false);
-
- return $location;
-}
-
-describe('DiscoveryEngine', function (): void {
- describe('addLocation', function (): void {
- it('adds a location', function (): void {
- $engine = createEngine();
- $location = createLocationMock('/tmp/test-path-'.uniqid());
-
- $result = $engine->addLocation($location);
-
- expect($result)->toBe($engine);
- expect($engine->getLocations())->toHaveCount(1);
- });
-
- it('deduplicates locations with same path', function (): void {
- $engine = createEngine();
- $path = '/tmp/dedup-path-'.uniqid();
- $location1 = createLocationMock($path);
- $location2 = createLocationMock($path);
-
- $engine->addLocation($location1);
- $engine->addLocation($location2);
-
- expect($engine->getLocations())->toHaveCount(1);
- });
-
- it('adds locations with different paths', function (): void {
- $engine = createEngine();
-
- $engine->addLocation(createLocationMock('/tmp/path-a-'.uniqid()));
- $engine->addLocation(createLocationMock('/tmp/path-b-'.uniqid()));
-
- expect($engine->getLocations())->toHaveCount(2);
- });
- });
-
- describe('addLocations', function (): void {
- it('adds multiple locations at once', function (): void {
- $engine = createEngine();
-
- $engine->addLocations([
- createLocationMock('/tmp/loc-1-'.uniqid()),
- createLocationMock('/tmp/loc-2-'.uniqid()),
- ]);
-
- expect($engine->getLocations())->toHaveCount(2);
- });
- });
-
- describe('addDiscovery', function (): void {
- it('adds a discovery instance', function (): void {
- $engine = createEngine();
- $discovery = Mockery::mock(DiscoveryInterface::class);
-
- $engine->addDiscovery('test', $discovery);
-
- expect($engine->getDiscoveries())->toHaveCount(1);
- expect($engine->getDiscoveries()->has('test'))->toBeTrue();
- });
-
- it('throws on duplicate identifier', function (): void {
- $engine = createEngine();
- $discovery = Mockery::mock(DiscoveryInterface::class);
-
- $engine->addDiscovery('dup', $discovery);
-
- expect(fn (): DiscoveryEngine => $engine->addDiscovery('dup', $discovery))
- ->toThrow(InvalidDiscoveryException::class);
- });
- });
-
- describe('addDiscoveries', function (): void {
- it('adds multiple discoveries at once', function (): void {
- $engine = createEngine();
-
- $engine->addDiscoveries([
- 'first' => Mockery::mock(DiscoveryInterface::class),
- 'second' => Mockery::mock(DiscoveryInterface::class),
- ]);
-
- expect($engine->getDiscoveries())->toHaveCount(2);
- });
- });
-
- describe('getDiscovery', function (): void {
- it('returns discovery by identifier', function (): void {
- $engine = createEngine();
- $discovery = Mockery::mock(DiscoveryInterface::class);
-
- $engine->addDiscovery('my-discovery', $discovery);
-
- expect($engine->getDiscovery('my-discovery'))->toBe($discovery);
- });
-
- it('throws when discovery not found', function (): void {
- $engine = createEngine();
-
- expect(fn (): DiscoveryInterface => $engine->getDiscovery('nonexistent'))
- ->toThrow(DiscoveryNotFoundException::class);
- });
- });
-
- describe('clearLocations', function (): void {
- it('removes all locations', function (): void {
- $engine = createEngine();
- $engine->addLocation(createLocationMock('/tmp/clear-'.uniqid()));
-
- expect($engine->getLocations())->toHaveCount(1);
-
- $engine->clearLocations();
-
- expect($engine->getLocations())->toHaveCount(0);
- });
-
- it('returns the engine for chaining', function (): void {
- $engine = createEngine();
-
- expect($engine->clearLocations())->toBe($engine);
- });
- });
-
- describe('clearCache', function (): void {
- it('delegates to cache manager', function (): void {
- $cacheManager = Mockery::mock(DiscoveryCacheManager::class);
- $cacheManager->shouldReceive('clearCache')->once();
-
- $engine = createEngine($cacheManager);
- $engine->clearCache();
- });
- });
-
- describe('getPerformanceStats', function (): void {
- it('returns stats array with expected keys', function (): void {
- $cacheManager = Mockery::mock(DiscoveryCacheManager::class);
- $cacheManager->shouldReceive('getStaticCacheSize')->andReturn(0);
-
- $engine = createEngine($cacheManager);
- $stats = $engine->getPerformanceStats();
-
- expect($stats)->toHaveKeys(['context', 'instance_pool', 'static_cache_size']);
- expect($stats['static_cache_size'])->toBe(0);
- });
- });
-
- describe('__clone', function (): void {
- it('creates independent copies of collections', function (): void {
- $engine = createEngine();
- $engine->addLocation(createLocationMock('/tmp/clone-'.uniqid()));
-
- $clone = clone $engine;
- $clone->addLocation(createLocationMock('/tmp/clone-extra-'.uniqid()));
-
- expect($engine->getLocations())->toHaveCount(1);
- expect($clone->getLocations())->toHaveCount(2);
- });
- });
-});
-
-describe('DiscoveryEngine apply', function (): void {
- /**
- * A discovery that records the items each apply() receives.
- */
- function recordingDiscovery(bool $failing = false): DiscoveryInterface
- {
- return new class($failing) implements DiscoveryInterface
- {
- use IsDiscovery;
-
- /** @var list> */
- public array $applied = [];
-
- public function __construct(private readonly bool $failing) {}
-
- public function discover(DiscoveryLocationInterface $location, DiscoveredStructure $structure, ?ReflectionCacheInterface $reflectionCache = null): void {}
-
- public function apply(): void
- {
- $this->applied[] = $this->getItems()->all();
-
- if ($this->failing) {
- throw new RuntimeException('apply failed');
- }
- }
- };
- }
-
- it('applies each item once, however many times it is asked to apply', function (): void {
- $engine = createEngine();
- $discovery = recordingDiscovery();
- $engine->addDiscovery('recording', $discovery);
- $app = new DiscoveryLocation('App\\', '/app');
- $theme = new DiscoveryLocation('Theme\\', '/theme');
-
- $discovery->getItems()->add($app, 'app-item');
- $engine->apply();
- $engine->apply();
- $discovery->getItems()->add($theme, 'theme-item');
- $engine->apply();
-
- expect($discovery->applied)->toBe([['app-item'], ['theme-item']]);
- });
-
- it('keeps every item readable after applying', function (): void {
- $engine = createEngine();
- $discovery = recordingDiscovery();
- $engine->addDiscovery('recording', $discovery);
- $location = new DiscoveryLocation('App\\', '/app');
-
- $discovery->getItems()->add($location, 'first');
- $engine->apply();
- $discovery->getItems()->add($location, 'second');
- $engine->apply();
-
- expect($discovery->getItems()->all())->toBe(['first', 'second']);
- });
-
- it('applies once for the engine and its clones, which share the discovery', function (): void {
- $engine = createEngine();
- $discovery = recordingDiscovery();
- $engine->addDiscovery('recording', $discovery);
- $discovery->getItems()->add(new DiscoveryLocation('App\\', '/app'), 'item');
-
- $engine->apply();
- (clone $engine)->apply();
-
- expect($discovery->applied)->toBe([['item']]);
- });
-
- it('does not call apply() when nothing new was discovered', function (): void {
- $engine = createEngine();
- $discovery = recordingDiscovery();
- $engine->addDiscovery('recording', $discovery);
-
- $engine->apply();
-
- expect($discovery->applied)->toBe([]);
- });
-
- it('does not retry items whose apply failed, and restores the full list', function (): void {
- $engine = createEngine();
- $discovery = recordingDiscovery(failing: true);
- $engine->addDiscovery('recording', $discovery);
- $discovery->getItems()->add(new DiscoveryLocation('App\\', '/app'), 'item');
-
- expect(fn (): DiscoveryEngine => $engine->apply())->toThrow(DiscoveryException::class);
-
- $engine->apply();
-
- expect($discovery->applied)->toBe([['item']])
- ->and($discovery->getItems()->all())->toBe(['item']);
- });
-});
-
-#[CapabilitySet]
-enum EngineTestAttributedEnum: string
-{
- case Value = 'value';
-}
-
-enum EngineTestPlainEnum: string
-{
- case Value = 'value';
-}
-
-final class EngineTestClass {}
-
-abstract class EngineTestAbstractClass {}
-
-describe('DiscoveryEngine structures', function (): void {
- it('hands discoveries concrete classes and enums carrying attributes', function (): void {
- $structures = array_map(
- fn (string $name): DiscoveredStructure => enum_exists($name)
- ? DiscoveredEnum::fromReflection(new ReflectionEnum($name))
- : DiscoveredClass::fromReflection(new ReflectionClass($name)),
- [EngineTestAttributedEnum::class, EngineTestPlainEnum::class, EngineTestClass::class, EngineTestAbstractClass::class]
- );
- $cacheManager = Mockery::mock(DiscoveryCacheManager::class);
- $cacheManager->shouldReceive('getStructuresForLocation')->andReturn($structures);
- $engine = createEngine($cacheManager);
- $seen = [];
- $discovery = new class($seen) implements DiscoveryInterface
- {
- use IsDiscovery;
-
- public function __construct(private array &$seen) {}
-
- public function discover(DiscoveryLocationInterface $location, DiscoveredStructure $structure, ?ReflectionCacheInterface $reflectionCache = null): void
- {
- $this->seen[] = $structure->name;
- }
-
- public function apply(): void {}
- };
- $engine->addDiscovery('seen', $discovery);
- $engine->addLocation(new DiscoveryLocation('Tests\\', __DIR__));
-
- $engine->discover();
-
- expect($seen)->toBe(['EngineTestAttributedEnum', 'EngineTestClass']);
- });
-});
diff --git a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryRegistrarTest.php b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryRegistrarTest.php
index d30a7ec5..b3c9e069 100644
--- a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryRegistrarTest.php
+++ b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryRegistrarTest.php
@@ -71,7 +71,7 @@ function createMockRegistrarEngine(): DiscoveryEngineInterface
->once()
->with('test_discovery', $discovery);
- $registered = $this->registrar->registerFromContainer($engine);
+ $registered = $this->registrar->registerInto($engine);
expect($registered)->toContain('test_discovery');
});
@@ -82,7 +82,7 @@ function createMockRegistrarEngine(): DiscoveryEngineInterface
$engine = createMockRegistrarEngine();
$engine->shouldNotReceive('addDiscovery');
- $registered = $this->registrar->registerFromContainer($engine);
+ $registered = $this->registrar->registerInto($engine);
expect($registered)->toBe([]);
});
@@ -96,7 +96,7 @@ function createMockRegistrarEngine(): DiscoveryEngineInterface
->andReturn(new Collection(['already_registered' => $discovery]));
$engine->shouldNotReceive('addDiscovery');
- $registered = $this->registrar->registerFromContainer($engine);
+ $registered = $this->registrar->registerInto($engine);
expect($registered)->toBe([]);
});
@@ -107,7 +107,7 @@ function createMockRegistrarEngine(): DiscoveryEngineInterface
$engine = createMockRegistrarEngine();
$engine->shouldNotReceive('addDiscovery');
- $registered = $this->registrar->registerFromContainer($engine);
+ $registered = $this->registrar->registerInto($engine);
expect($registered)->toBe([]);
});
@@ -118,7 +118,7 @@ function createMockRegistrarEngine(): DiscoveryEngineInterface
$engine = createMockRegistrarEngine();
$engine->shouldNotReceive('addDiscovery');
- $registered = $this->registrar->registerFromContainer($engine);
+ $registered = $this->registrar->registerInto($engine);
expect($registered)->toBe([]);
});
diff --git a/tests/Unit/PostType/PostTypeDiscoveryTest.php b/tests/Unit/PostType/PostTypeDiscoveryTest.php
new file mode 100644
index 00000000..be45aed2
--- /dev/null
+++ b/tests/Unit/PostType/PostTypeDiscoveryTest.php
@@ -0,0 +1,114 @@
+ */
+ public array $calls = [];
+
+ #[AdminCol('isbn', 'ISBN', sortable: true)]
+ public function isbnColumn(): string
+ {
+ return '978';
+ }
+
+ #[RegisterMetaBoxCb]
+ public function metaBoxes(): void
+ {
+ $this->calls[] = 'metaBoxes';
+ }
+
+ #[Action('init')]
+ public function onInit(): void
+ {
+ $this->calls[] = 'onInit';
+ }
+}
+
+/**
+ * Run the discovery over the given classes and return what reached the post type service.
+ *
+ * @return array{slug: string, singular: string, plural: string, args: array, priority: int}
+ */
+function registeredPostType(string $class): array
+{
+ $registered = [];
+ $service = Mockery::mock(PostTypeServiceInterface::class);
+ $service->shouldReceive('register')->once()->andReturnUsing(
+ function (string $slug, string $singular, string $plural, array $args, int $priority) use (&$registered): object {
+ $registered = ['slug' => $slug, 'singular' => $singular, 'plural' => $plural, 'args' => $args, 'priority' => $priority];
+
+ return new stdClass;
+ }
+ );
+
+ $discovery = new PostTypeDiscovery($service);
+ $discovery->discover(new DiscoveryLocation('Tests\\', __DIR__), DiscoveredClass::fromReflection(new ReflectionClass($class)), new ReflectionCache);
+ $discovery->apply();
+
+ return $registered;
+}
+
+it('registers the arguments its attributes declare', function (): void {
+ $registered = registeredPostType(DiscoveredBook::class);
+
+ expect($registered['slug'])->toBe('fixture_book')
+ ->and($registered['priority'])->toBe(7)
+ ->and($registered['args'])->toMatchArray([
+ 'has_archive' => 'library',
+ 'supports' => ['title', 'excerpt'],
+ 'orderby' => 'date',
+ 'order' => 'DESC',
+ 'text_domain' => 'pollora',
+ ])
+ ->and($registered['args'])->not->toHaveKey('priority');
+});
+
+it('merges declared labels into the generated ones', function (): void {
+ $labels = registeredPostType(DiscoveredBook::class)['args']['labels'];
+
+ expect($labels['menu_name'])->toBe('Library')
+ ->and($labels['singular_name'])->toBe('Book');
+});
+
+it('turns method attributes into callbacks on the declaring class', function (): void {
+ $args = registeredPostType(DiscoveredBook::class)['args'];
+
+ expect($args['admin_cols']['isbn'])->toMatchArray(['title' => 'ISBN', 'sortable' => true])
+ ->and($args['admin_cols']['isbn']['function'][0])->toBeInstanceOf(DiscoveredBook::class)
+ ->and($args['admin_cols']['isbn']['function'][1])->toBe('isbnColumn')
+ ->and($args['register_meta_box_cb'][0])->toBeInstanceOf(DiscoveredBook::class)
+ ->and($args['register_meta_box_cb'][1])->toBe('metaBoxes');
+});
+
+it('leaves the actions of a post type class to the hook discovery', function (): void {
+ $actions = Mockery::mock(ActionService::class);
+ $actions->shouldNotReceive('add');
+
+ app()->instance(ActionService::class, $actions);
+
+ registeredPostType(DiscoveredBook::class);
+});
diff --git a/tests/Unit/Schedule/Application/UseCases/RegisterSchedulerFiltersUseCaseTest.php b/tests/Unit/Schedule/Application/UseCases/RegisterSchedulerFiltersUseCaseTest.php
deleted file mode 100644
index 27641a5a..00000000
--- a/tests/Unit/Schedule/Application/UseCases/RegisterSchedulerFiltersUseCaseTest.php
+++ /dev/null
@@ -1,72 +0,0 @@
-filter = m::mock(Filter::class);
- $this->scheduler = m::mock(SchedulerInterface::class);
- $this->useCase = new RegisterSchedulerFiltersUseCase($this->filter, $this->scheduler);
-});
-
-describe('RegisterSchedulerFiltersUseCase', function (): void {
-
- it('registers all nine WordPress cron filters', function (): void {
- $this->filter->shouldReceive('add')->times(9);
-
- $this->useCase->execute();
- });
-
- it('registers event query filters with correct hooks', function (): void {
- $scheduler = $this->scheduler;
-
- $this->filter->shouldReceive('add')
- ->with('pre_get_scheduled_event', [$scheduler, 'preGetScheduledEvent'], 10, 5)
- ->once();
- $this->filter->shouldReceive('add')
- ->with('pre_get_ready_cron_jobs', [$scheduler, 'preGetReadyCronJobs'], 10, 5)
- ->once();
-
- // Allow the management filters
- $this->filter->shouldReceive('add')->times(7);
-
- $this->useCase->execute();
- });
-
- it('registers event management filters with correct hooks', function (): void {
- $scheduler = $this->scheduler;
-
- $expectedManagementFilters = [
- 'pre_update_option_cron' => 'preUpdateOptionCron',
- 'pre_option_cron' => 'preOptionCron',
- 'pre_schedule_event' => 'preScheduleEvent',
- 'pre_reschedule_event' => 'preRescheduleEvent',
- 'pre_unschedule_event' => 'preUnscheduleEvent',
- 'pre_clear_scheduled_hook' => 'preClearScheduledHook',
- 'pre_unschedule_hook' => 'preUnscheduleHook',
- ];
-
- foreach ($expectedManagementFilters as $hook => $method) {
- $this->filter->shouldReceive('add')
- ->with($hook, [$scheduler, $method], 10, 5)
- ->once();
- }
-
- // Allow the query filters
- $this->filter->shouldReceive('add')->times(2);
-
- $this->useCase->execute();
- });
-
- it('registers all filters with priority 10 and 5 accepted args', function (): void {
- $this->filter->shouldReceive('add')
- ->with(m::type('string'), m::type('array'), 10, 5)
- ->times(9);
-
- $this->useCase->execute();
- });
-});
diff --git a/tests/Unit/Schedule/EveryTest.php b/tests/Unit/Schedule/EveryTest.php
deleted file mode 100644
index 68386522..00000000
--- a/tests/Unit/Schedule/EveryTest.php
+++ /dev/null
@@ -1,64 +0,0 @@
-toScheduleKey())->toBe('hourly');
- expect(Every::TWICE_DAILY->toScheduleKey())->toBe('twicedaily');
- expect(Every::DAY->toScheduleKey())->toBe('daily');
- expect(Every::WEEK->toScheduleKey())->toBe('weekly');
- expect(Every::MONTH->toScheduleKey())->toBe('monthly');
- expect(Every::YEAR->toScheduleKey())->toBe('yearly');
- });
-
- it('identifies custom schedules correctly', function (): void {
- expect(Every::HOUR->isCustom())->toBeFalse();
- expect(Every::TWICE_DAILY->isCustom())->toBeFalse();
- expect(Every::DAY->isCustom())->toBeFalse();
- expect(Every::WEEK->isCustom())->toBeFalse();
- expect(Every::MONTH->isCustom())->toBeTrue();
- expect(Every::YEAR->isCustom())->toBeTrue();
- });
-
- it('converts to correct Interval instances', function (): void {
- $hourInterval = Every::HOUR->toInterval();
- expect($hourInterval)->toBeInstanceOf(Interval::class);
- expect($hourInterval->totalSeconds())->toBe(3600);
-
- $twiceDailyInterval = Every::TWICE_DAILY->toInterval();
- expect($twiceDailyInterval->totalSeconds())->toBe(43200);
-
- $dayInterval = Every::DAY->toInterval();
- expect($dayInterval->totalSeconds())->toBe(86400);
-
- $weekInterval = Every::WEEK->toInterval();
- expect($weekInterval->totalSeconds())->toBe(604800);
-
- $monthInterval = Every::MONTH->toInterval();
- expect($monthInterval->totalSeconds())->toBe(2592000);
-
- $yearInterval = Every::YEAR->toInterval();
- expect($yearInterval->totalSeconds())->toBe(31536000);
- });
-
- it('provides meaningful display text in intervals', function (): void {
- expect(Every::HOUR->toInterval()->display)->toBe('Once Hourly');
- expect(Every::DAY->toInterval()->display)->toBe('Once Daily');
- expect(Every::WEEK->toInterval()->display)->toBe('Once Weekly');
- expect(Every::MONTH->toInterval()->display)->toBe('Once Monthly');
- expect(Every::YEAR->toInterval()->display)->toBe('Once Yearly');
- });
-
- it('generates valid schedule arrays for custom intervals', function (): void {
- $monthArray = Every::MONTH->toInterval()->toScheduleArray();
-
- expect($monthArray)->toHaveKeys(['interval', 'display']);
- expect($monthArray['interval'])->toBeInt();
- expect($monthArray['interval'])->toBeGreaterThan(0);
- expect($monthArray['display'])->toBeString();
- });
-});
diff --git a/tests/Unit/Schedule/IntervalTest.php b/tests/Unit/Schedule/IntervalTest.php
deleted file mode 100644
index 5630b804..00000000
--- a/tests/Unit/Schedule/IntervalTest.php
+++ /dev/null
@@ -1,67 +0,0 @@
-totalSeconds())->toBe(30 + 300 + 7200 + 86400 + 604800);
- });
-
- it('returns zero for empty interval', function (): void {
- $interval = new Interval;
-
- expect($interval->totalSeconds())->toBe(0);
- });
-
- it('calculates seconds from hours only', function (): void {
- $interval = new Interval(hours: 1);
-
- expect($interval->totalSeconds())->toBe(3600);
- });
-
- it('calculates seconds from days only', function (): void {
- $interval = new Interval(days: 30);
-
- expect($interval->totalSeconds())->toBe(2592000);
- });
-
- it('calculates seconds from weeks only', function (): void {
- $interval = new Interval(weeks: 1);
-
- expect($interval->totalSeconds())->toBe(604800);
- });
-
- it('returns schedule array with interval and display', function (): void {
- $interval = new Interval(hours: 12, display: 'Twice Daily');
-
- $array = $interval->toScheduleArray();
-
- expect($array)->toBe([
- 'interval' => 43200,
- 'display' => 'Twice Daily',
- ]);
- });
-
- it('uses default display text', function (): void {
- $interval = new Interval(minutes: 30);
-
- expect($interval->display)->toBe('Custom schedule');
- });
-
- it('is readonly', function (): void {
- $reflection = new ReflectionClass(Interval::class);
-
- expect($reflection->isReadOnly())->toBeTrue();
- });
-});
diff --git a/tests/Unit/Support/SlugTest.php b/tests/Unit/Support/SlugTest.php
new file mode 100644
index 00000000..3b662ca5
--- /dev/null
+++ b/tests/Unit/Support/SlugTest.php
@@ -0,0 +1,20 @@
+toBe('user-management')
+ ->and(Slug::fromClassName('Cache'))->toBe('cache');
+});
+
+it('derives a subcommand from a method name', function (): void {
+ expect(Slug::fromMethodName('deleteById'))->toBe('delete-by-id')
+ ->and(Slug::toKebabCase('listItems'))->toBe('list-items');
+});
diff --git a/tests/Unit/Taxonomy/TaxonomyDiscoveryTest.php b/tests/Unit/Taxonomy/TaxonomyDiscoveryTest.php
new file mode 100644
index 00000000..c025d307
--- /dev/null
+++ b/tests/Unit/Taxonomy/TaxonomyDiscoveryTest.php
@@ -0,0 +1,86 @@
+ */
+ public array $calls = [];
+
+ #[MetaBoxCb]
+ public function metaBox(): void
+ {
+ $this->calls[] = 'metaBox';
+ }
+
+ #[Action('init')]
+ public function onInit(): void
+ {
+ $this->calls[] = 'onInit';
+ }
+}
+
+/**
+ * Run the discovery over the class and return the arguments it registered.
+ *
+ * @return array
+ */
+function registeredTaxonomyArgs(string $class): array
+{
+ $registered = [];
+ $service = Mockery::mock(TaxonomyServiceInterface::class);
+ $service->shouldReceive('register')->once()->andReturnUsing(
+ function (string $slug, string|array $objectType, string $singular, string $plural, array $args) use (&$registered): object {
+ $registered = $args;
+
+ return new stdClass;
+ }
+ );
+
+ $discovery = new TaxonomyDiscovery($service);
+ $discovery->discover(new DiscoveryLocation('Tests\\', __DIR__), DiscoveredClass::fromReflection(new ReflectionClass($class)), new ReflectionCache);
+ $discovery->apply();
+
+ return $registered;
+}
+
+it('registers the arguments its attributes declare, labels merged', function (): void {
+ $args = registeredTaxonomyArgs(DiscoveredGenre::class);
+
+ expect($args)->toMatchArray(['hierarchical' => true, 'show_in_rest' => true])
+ ->and($args['labels']['menu_name'])->toBe('Shelves')
+ ->and($args['labels']['singular_name'])->toBe('Genre');
+});
+
+it('turns a method attribute into a callback on the declaring class', function (): void {
+ $callback = registeredTaxonomyArgs(DiscoveredGenre::class)['meta_box_cb'];
+
+ expect($callback[0])->toBeInstanceOf(DiscoveredGenre::class)
+ ->and($callback[1])->toBe('metaBox');
+});
+
+it('leaves the actions of a taxonomy class to the hook discovery', function (): void {
+ $actions = Mockery::mock(ActionService::class);
+ $actions->shouldNotReceive('add');
+
+ app()->instance(ActionService::class, $actions);
+
+ registeredTaxonomyArgs(DiscoveredGenre::class);
+});
diff --git a/tests/Unit/WordPress/BootstrapEnvironmentTest.php b/tests/Unit/WordPress/BootstrapEnvironmentTest.php
new file mode 100644
index 00000000..599e810a
--- /dev/null
+++ b/tests/Unit/WordPress/BootstrapEnvironmentTest.php
@@ -0,0 +1,174 @@
+registerWordPressConstants();
+
+ self::assertTrue(WP_DEBUG);
+ self::assertSame('local', constant('WP_ENVIRONMENT_TYPE'));
+ }
+
+ #[DataProvider('environmentMappings')]
+ public function test_application_environment_is_mapped(?string $environment, string $expected): void
+ {
+ putenv('WP_ENVIRONMENT_TYPE');
+ $this->registerWordPressConstants($environment);
+
+ self::assertSame($expected, constant('WP_ENVIRONMENT_TYPE'));
+ }
+
+ #[DataProvider('debugValues')]
+ public function test_environment_does_not_depend_on_debug_mode(bool $debug): void
+ {
+ putenv('WP_ENVIRONMENT_TYPE');
+ $this->registerWordPressConstants(debug: $debug);
+
+ self::assertSame($debug, WP_DEBUG);
+ self::assertSame('local', constant('WP_ENVIRONMENT_TYPE'));
+ }
+
+ #[DataProvider('nativeEnvironments')]
+ public function test_native_environment_prevents_fallback_constant(string $environment): void
+ {
+ putenv('WP_ENVIRONMENT_TYPE='.$environment);
+ $this->registerWordPressConstants();
+
+ self::assertFalse(defined('WP_ENVIRONMENT_TYPE'));
+ self::assertSame($environment, getenv('WP_ENVIRONMENT_TYPE'));
+ }
+
+ #[DataProvider('explicitValues')]
+ public function test_configured_environment_is_not_normalized(mixed $value): void
+ {
+ putenv('WP_ENVIRONMENT_TYPE');
+ $this->registerWordPressConstants(constants: ['wp_environment_type' => $value]);
+
+ self::assertSame($value, constant('WP_ENVIRONMENT_TYPE'));
+ }
+
+ public function test_configured_constant_still_applies_with_native_environment(): void
+ {
+ putenv('WP_ENVIRONMENT_TYPE=production');
+ $this->registerWordPressConstants(constants: ['WP_ENVIRONMENT_TYPE' => 'staging']);
+
+ self::assertSame('staging', constant('WP_ENVIRONMENT_TYPE'));
+ self::assertSame('production', getenv('WP_ENVIRONMENT_TYPE'));
+ }
+
+ #[DataProvider('explicitValues')]
+ public function test_predefined_constant_is_not_overwritten(mixed $value): void
+ {
+ putenv('WP_ENVIRONMENT_TYPE=production');
+ define('WP_ENVIRONMENT_TYPE', $value);
+ $this->registerWordPressConstants(constants: ['WP_ENVIRONMENT_TYPE' => 'development']);
+
+ self::assertSame($value, constant('WP_ENVIRONMENT_TYPE'));
+ }
+
+ /**
+ * @return array
+ */
+ public static function environmentMappings(): array
+ {
+ return [
+ 'local' => ['local', 'local'],
+ 'dev' => ['dev', 'development'],
+ 'development' => ['development', 'development'],
+ 'testing' => ['testing', 'development'],
+ 'staging' => ['staging', 'staging'],
+ 'production' => ['production', 'production'],
+ 'prod' => ['prod', 'production'],
+ 'unknown' => ['preview', 'production'],
+ 'empty' => ['', 'production'],
+ 'missing' => [null, 'production'],
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public static function debugValues(): array
+ {
+ return ['enabled' => [true], 'disabled' => [false]];
+ }
+
+ /**
+ * @return array
+ */
+ public static function nativeEnvironments(): array
+ {
+ return [
+ 'local' => ['local'],
+ 'development' => ['development'],
+ 'staging' => ['staging'],
+ 'production' => ['production'],
+ 'empty' => [''],
+ 'invalid' => ['preview'],
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public static function explicitValues(): array
+ {
+ return [
+ 'valid' => ['staging'],
+ 'empty' => [''],
+ 'invalid' => ['preview'],
+ 'false' => [false],
+ ];
+ }
+
+ /**
+ * @param array $constants
+ */
+ private function registerWordPressConstants(?string $environment = 'local', array $constants = [], bool $debug = true): void
+ {
+ $app = new Application(dirname(__DIR__, 3));
+ $app->instance('config', new Repository([
+ 'app' => ['env' => $environment, 'debug' => $debug, 'url' => 'http://environment.test'],
+ 'wordpress' => ['constants' => $constants],
+ ]));
+ $app->instance('request', Request::create('/'));
+ $app->instance('constant.manager', new ConstantManager);
+ Facade::setFacadeApplication($app);
+
+ $bootstrap = new Bootstrap(
+ new ConsoleDetectionService(new LaravelConsoleDetector($app)),
+ new LaravelDebugDetector($app),
+ \Mockery::mock(Action::class),
+ );
+
+ $bootstrap->register();
+ }
+}
diff --git a/tests/Unit/WpCli/Application/Services/WpCliServiceTest.php b/tests/Unit/WpCli/Application/Services/WpCliServiceTest.php
deleted file mode 100644
index a6210720..00000000
--- a/tests/Unit/WpCli/Application/Services/WpCliServiceTest.php
+++ /dev/null
@@ -1,98 +0,0 @@
-adapter = new WpCliAdapter;
- $this->service = new WpCliService($this->adapter);
- });
-
- describe('register', function (): void {
- it('stores command in registered commands', function (): void {
- $this->service->register('pollora test', 'TestCommand', 'A test command', 10);
-
- expect($this->service->hasCommand('pollora test'))->toBeTrue();
- });
-
- it('stores command metadata correctly', function (): void {
- $this->service->register('pollora test', 'TestCommand', 'A test command', 10, ['when' => 'before_wp_load']);
-
- $command = $this->service->getCommand('pollora test');
-
- expect($command['class'])->toBe('TestCommand');
- expect($command['description'])->toBe('A test command');
- expect($command['priority'])->toBe(10);
- expect($command['args'])->toBe(['when' => 'before_wp_load']);
- });
-
- it('accepts array handler', function (): void {
- $handler = ['SomeClass', 'someMethod'];
-
- $this->service->register('pollora array-cmd', $handler, 'Array handler');
-
- $command = $this->service->getCommand('pollora array-cmd');
- expect($command['class'])->toBe($handler);
- });
-
- it('stores default values when not provided', function (): void {
- $this->service->register('pollora minimal', 'MinimalCommand');
-
- $command = $this->service->getCommand('pollora minimal');
-
- expect($command['description'])->toBe('');
- expect($command['priority'])->toBe(0);
- expect($command['args'])->toBe([]);
- });
- });
-
- describe('hasCommand', function (): void {
- it('returns false for unregistered command', function (): void {
- expect($this->service->hasCommand('nonexistent'))->toBeFalse();
- });
-
- it('returns true for registered command', function (): void {
- $this->service->register('pollora exists', 'ExistsCommand');
-
- expect($this->service->hasCommand('pollora exists'))->toBeTrue();
- });
- });
-
- describe('getCommand', function (): void {
- it('returns null for unregistered command', function (): void {
- expect($this->service->getCommand('nonexistent'))->toBeNull();
- });
- });
-
- describe('getRegisteredCommands', function (): void {
- it('returns empty array initially', function (): void {
- expect($this->service->getRegisteredCommands())->toBe([]);
- });
-
- it('returns all registered commands', function (): void {
- $this->service->register('cmd1', 'Class1');
- $this->service->register('cmd2', 'Class2');
-
- $commands = $this->service->getRegisteredCommands();
-
- expect($commands)->toHaveCount(2);
- expect($commands)->toHaveKeys(['cmd1', 'cmd2']);
- });
- });
-
- describe('initializeCommands', function (): void {
- it('does not throw when WP CLI is unavailable', function (): void {
- $this->service->register('cmd1', 'Class1');
-
- // Should not throw — WP CLI is not available, so it silently skips
- $this->service->initializeCommands();
-
- expect($this->service->hasCommand('cmd1'))->toBeTrue();
- });
- });
-});
diff --git a/tests/Unit/WpCli/Infrastructure/Adapters/WpCliAdapterTest.php b/tests/Unit/WpCli/Infrastructure/Adapters/WpCliAdapterTest.php
deleted file mode 100644
index 9b0b1522..00000000
--- a/tests/Unit/WpCli/Infrastructure/Adapters/WpCliAdapterTest.php
+++ /dev/null
@@ -1,121 +0,0 @@
-adapter = new WpCliAdapter;
- });
-
- describe('isAvailable', function (): void {
- it('returns false when WP_CLI is not defined', function (): void {
- // WP_CLI is not defined in test environment
- expect($this->adapter->isAvailable())->toBeFalse();
- });
- });
-
- describe('addCommand', function (): void {
- it('throws RuntimeException when WP_CLI is not available', function (): void {
- expect(fn () => $this->adapter->addCommand('test', 'SomeClass'))
- ->toThrow(RuntimeException::class, 'WP-CLI is not available');
- });
- });
-
- describe('hasCommand', function (): void {
- it('returns false when WP_CLI is not available', function (): void {
- expect($this->adapter->hasCommand('test'))->toBeFalse();
- });
- });
-
- describe('getVersion', function (): void {
- it('returns null when WP_CLI is not available', function (): void {
- expect($this->adapter->getVersion())->toBeNull();
- });
- });
-
- describe('log', function (): void {
- it('does nothing when WP_CLI is not available', function (): void {
- // Should not throw
- $this->adapter->log('test message', 'debug');
- $this->adapter->log('test message', 'warning');
- $this->adapter->log('test message', 'error');
- $this->adapter->log('test message', 'success');
- $this->adapter->log('test message');
-
- expect(true)->toBeTrue(); // Assert no exception
- });
- });
-});
-
-describe('WpCliAdapter validation', function (): void {
- /*
- * Since validateHandler is private, we test it indirectly through addCommand.
- * When WP_CLI is unavailable, RuntimeException is thrown before validation.
- * We use Reflection to test validation logic directly.
- */
- beforeEach(function (): void {
- $this->adapter = new WpCliAdapter;
- $this->validate = new ReflectionMethod(WpCliAdapter::class, 'validateHandler');
- });
-
- it('accepts a valid class string', function (): void {
- // stdClass exists
- $this->validate->invoke($this->adapter, stdClass::class);
-
- expect(true)->toBeTrue();
- });
-
- it('rejects a non-existent class string', function (): void {
- expect(fn () => $this->validate->invoke($this->adapter, 'NonExistentClass'))
- ->toThrow(InvalidArgumentException::class, 'does not exist');
- });
-
- it('accepts a valid [object, method] array', function (): void {
- $handler = [new class
- {
- public function handle(): void {}
- }, 'handle'];
-
- $this->validate->invoke($this->adapter, $handler);
-
- expect(true)->toBeTrue();
- });
-
- it('rejects array with wrong count', function (): void {
- expect(fn () => $this->validate->invoke($this->adapter, ['only-one']))
- ->toThrow(InvalidArgumentException::class, 'exactly 2 elements');
- });
-
- it('rejects array where first element is not an object', function (): void {
- expect(fn () => $this->validate->invoke($this->adapter, ['string', 'method']))
- ->toThrow(InvalidArgumentException::class, 'must be an object');
- });
-
- it('rejects array where second element is not a string', function (): void {
- expect(fn () => $this->validate->invoke($this->adapter, [new stdClass, 42]))
- ->toThrow(InvalidArgumentException::class, 'must be a method name string');
- });
-
- it('rejects array where method does not exist', function (): void {
- expect(fn () => $this->validate->invoke($this->adapter, [new stdClass, 'nonExistent']))
- ->toThrow(InvalidArgumentException::class, 'does not exist on class');
- });
-
- it('accepts an invokable object', function (): void {
- $handler = new class
- {
- public function __invoke(): void {}
- };
-
- $this->validate->invoke($this->adapter, $handler);
-
- expect(true)->toBeTrue();
- });
-
- it('rejects a non-invokable object', function (): void {
- expect(fn () => $this->validate->invoke($this->adapter, new stdClass))
- ->toThrow(InvalidArgumentException::class, 'must be invokable');
- });
-});
diff --git a/tests/Unit/WpCli/Infrastructure/Adapters/WpCliMethodWrapperTest.php b/tests/Unit/WpCli/Infrastructure/Adapters/WpCliMethodWrapperTest.php
deleted file mode 100644
index d235251e..00000000
--- a/tests/Unit/WpCli/Infrastructure/Adapters/WpCliMethodWrapperTest.php
+++ /dev/null
@@ -1,102 +0,0 @@
-instance = new class
- {
- /**
- * Test command documentation.
- *
- * ## OPTIONS
- *
- * : The name to greet.
- */
- public function greet(array $args, array $assocArgs): string
- {
- return 'Hello '.$args[0];
- }
-
- private function secretMethod(array $args): string
- {
- return 'secret: '.$args[0];
- }
- };
- });
-
- it('invokes the original method via __invoke', function (): void {
- $method = new ReflectionMethod($this->instance, 'greet');
- $wrapper = new WpCliMethodWrapper($this->instance, $method);
-
- $result = $wrapper(['World'], []);
-
- expect($result)->toBe('Hello World');
- });
-
- it('delegates __call to original method by name', function (): void {
- $method = new ReflectionMethod($this->instance, 'greet');
- $wrapper = new WpCliMethodWrapper($this->instance, $method);
-
- $result = $wrapper->greet(['World'], []);
-
- expect($result)->toBe('Hello World');
- });
-
- it('throws BadMethodCallException for unknown method via __call', function (): void {
- $method = new ReflectionMethod($this->instance, 'greet');
- $wrapper = new WpCliMethodWrapper($this->instance, $method);
-
- expect(fn () => $wrapper->unknownMethod())
- ->toThrow(BadMethodCallException::class, 'does not exist');
- });
-
- it('caches and returns the docblock', function (): void {
- $method = new ReflectionMethod($this->instance, 'greet');
- $wrapper = new WpCliMethodWrapper($this->instance, $method);
-
- $doc = $wrapper->getDocComment();
-
- expect($doc)->toBeString();
- expect($doc)->toContain('Test command documentation');
- expect($doc)->toContain('## OPTIONS');
- });
-
- it('returns false when method has no docblock', function (): void {
- $obj = new class
- {
- public function noDoc(array $args, array $assocArgs): void {}
- };
-
- $method = new ReflectionMethod($obj, 'noDoc');
- $wrapper = new WpCliMethodWrapper($obj, $method);
-
- expect($wrapper->getDocComment())->toBeFalse();
- });
-
- it('returns the original method reflection', function (): void {
- $method = new ReflectionMethod($this->instance, 'greet');
- $wrapper = new WpCliMethodWrapper($this->instance, $method);
-
- expect($wrapper->getOriginalMethod())->toBe($method);
- });
-
- it('returns the original instance', function (): void {
- $method = new ReflectionMethod($this->instance, 'greet');
- $wrapper = new WpCliMethodWrapper($this->instance, $method);
-
- expect($wrapper->getOriginalInstance())->toBe($this->instance);
- });
-
- it('can invoke private methods via reflection', function (): void {
- $method = new ReflectionMethod($this->instance, 'secretMethod');
-
- $wrapper = new WpCliMethodWrapper($this->instance, $method);
-
- $result = $wrapper(['data'], []);
-
- expect($result)->toBe('secret: data');
- });
-});
diff --git a/tests/Unit/WpRest/CanPermissionTest.php b/tests/Unit/WpRest/CanPermissionTest.php
deleted file mode 100644
index e2c2b1e9..00000000
--- a/tests/Unit/WpRest/CanPermissionTest.php
+++ /dev/null
@@ -1,45 +0,0 @@
-justReturn(401);
-});
-
-it('allows a user who has the capability', function (): void {
- Functions\expect('current_user_can')->once()->with('export_attendees')->andReturn(true);
-
- expect((new Can(EventCap::ExportAttendees))->allow(Mockery::mock(WP_REST_Request::class)))->toBeTrue();
-});
-
-it('passes the value of a request parameter, for a meta capability', function (): void {
- $request = Mockery::mock(WP_REST_Request::class);
- $request->shouldReceive('get_param')->with('id')->andReturn('42');
- Functions\expect('current_user_can')->once()->with('edit_post', '42')->andReturn(true);
-
- expect((new Can('edit_post', parameter: 'id'))->allow($request))->toBeTrue();
-});
-
-it("refuses with WordPress's authorization status", function (): void {
- Functions\when('current_user_can')->justReturn(false);
-
- expect((new Can('manage_options'))->allow(Mockery::mock(WP_REST_Request::class)))->toBeInstanceOf(WP_Error::class);
-});
-
-it('is accepted as an instance by #[Method] and #[WpRestRoute]', function (): void {
- Functions\when('current_user_can')->justReturn(true);
- $resolve = new ReflectionMethod(Method::class, 'resolvePermissionCallback');
-
- $callback = $resolve->invoke(new Method('GET', permissionCallback: new Can('edit_posts')), new Can('edit_posts'));
-
- expect($callback(Mockery::mock(WP_REST_Request::class)))->toBeTrue();
-});
diff --git a/tests/Unit/WpRest/RestRequestInjectionTest.php b/tests/Unit/WpRest/RestRequestInjectionTest.php
deleted file mode 100644
index d69922c1..00000000
--- a/tests/Unit/WpRest/RestRequestInjectionTest.php
+++ /dev/null
@@ -1,52 +0,0 @@
-getParameters()[0];
-
- $method = (new ReflectionClass(Method::class))->getMethod('expectsTheRequest');
-
- return $method->invoke(new Method('GET'), $param);
-}
-
-describe('Method::expectsTheRequest()', function (): void {
- it('gives the request to a parameter that asks for it by type', function (): void {
- expect(expectsRequest('withRequest'))->toBeTrue();
- });
-
- it('leaves a route parameter alone', function (): void {
- expect(expectsRequest('withRouteParam'))->toBeFalse();
- });
-
- it('leaves an untyped parameter alone', function (): void {
- expect(expectsRequest('withUntyped'))->toBeFalse();
- });
-
- it('leaves a builtin type alone', function (): void {
- // int, string and friends name route values, never the request.
- expect(expectsRequest('withBuiltin'))->toBeFalse();
- });
-});
diff --git a/tests/Unit/WpRest/WpRestDiscoveryTest.php b/tests/Unit/WpRest/WpRestDiscoveryTest.php
deleted file mode 100644
index ed292d5b..00000000
--- a/tests/Unit/WpRest/WpRestDiscoveryTest.php
+++ /dev/null
@@ -1,230 +0,0 @@
- $namespace, 'route' => $route, 'args' => $args];
-
- return true;
- }
-}
-
-if (! function_exists('add_action')) {
- function add_action($hook, $callback, $priority = 10, $accepted_args = 1): bool
- {
- global $wp_actions;
- $wp_actions[$hook][] = $callback;
-
- return true;
- }
-}
-
-// Test class that doesn't implement Attributable (real-world scenario)
-#[WpRestRoute('api/v1', '/documents/(?P\\d+)')]
-class TestDocumentAPI
-{
- #[Method('GET')]
- public function get(int $documentId): array
- {
- return ['id' => $documentId, 'title' => 'Test Document'];
- }
-
- #[Method(['POST', 'DELETE'])]
- public function modify(int $documentId): array
- {
- return ['id' => $documentId, 'modified' => true];
- }
-}
-
-// Test class without WpRestRoute attribute
-class RegularController
-{
- public function someMethod(): string
- {
- return 'regular';
- }
-}
-
-// Test class that is abstract
-#[WpRestRoute('api/v1', '/abstract')]
-abstract class AbstractController
-{
- #[Method('GET')]
- abstract public function get(): array;
-}
-
-// Mock classes for testing - simplified version without Spatie dependencies
-class MockDiscoveredAttribute
-{
- public function __construct(
- public readonly string $name,
- public readonly string $class,
- public readonly array $arguments = []
- ) {}
-
- public function newInstance()
- {
- return new $this->class(...$this->arguments);
- }
-}
-
-class MockDiscoveredClass
-{
- public function __construct(
- public readonly string $name,
- public readonly string $namespace,
- public readonly string $file,
- public readonly array $attributes = [],
- public readonly bool $isAbstract = false
- ) {}
-}
-
-describe('WpRestDiscovery', function (): void {
-
- beforeEach(function (): void {
- global $registered_routes, $wp_actions;
- $registered_routes = [];
- $wp_actions = [];
- });
-
- test('discover method processes only DiscoveredClass instances', function (): void {
- $discovery = new WpRestDiscovery;
-
- // Test that discovery starts empty
- expect($discovery->getItems()->all())->toHaveCount(0);
-
- // Test that we can add items manually (simulating proper discovery)
- $location = new DiscoveryLocation('Test\\', '/test/path');
- $discovery->getItems()->add($location, ['test' => 'item']);
-
- expect($discovery->getItems()->all())->toHaveCount(1);
- });
-
- test('basic discovery functionality works', function (): void {
- $discovery = new WpRestDiscovery;
-
- // Test that we can manually add items (simulating discovery)
- $location = new DiscoveryLocation('Test\\', '/test/path');
- $mockAttribute = new MockDiscoveredAttribute(
- name: WpRestRoute::class,
- class: WpRestRoute::class,
- arguments: ['api/v1', '/test']
- );
-
- $discovery->getItems()->add($location, [
- 'class' => 'TestClass',
- 'attribute' => $mockAttribute,
- 'structure' => new MockDiscoveredClass('TestClass', '', '/test.php'),
- ]);
-
- expect($discovery->getItems()->all())->toHaveCount(1);
- });
-
- test('registers REST routes when applying discovered items', function (): void {
- global $registered_routes, $wp_actions;
-
- $discovery = new WpRestDiscovery;
- $location = new DiscoveryLocation('', '/test/path');
- $reflectionCache = new ReflectionCache;
-
- // Add a simple test item to verify the apply logic works
- $discovery->getItems()->add($location, [
- 'class' => 'TestDocumentAPI',
- 'attribute' => new MockDiscoveredAttribute(
- name: WpRestRoute::class,
- class: WpRestRoute::class,
- arguments: ['api/v1', '/documents/(?P\\d+)']
- ),
- 'structure' => new MockDiscoveredClass(
- name: 'TestDocumentAPI',
- namespace: '',
- file: '/test/TestDocumentAPI.php'
- ),
- 'reflection_cache' => $reflectionCache,
- ]);
-
- // The apply method should not throw even if reflection fails
- expect(fn () => $discovery->apply())->not->toThrow(Exception::class);
-
- // Verify that items were processed (we don't check wp_actions as it depends on complex logic)
- expect($discovery->getItems()->all())->toHaveCount(1);
- });
-
- test('handles reflection errors gracefully', function (): void {
- $discovery = new WpRestDiscovery;
- $location = new DiscoveryLocation('', '/test/path');
- $reflectionCache = new ReflectionCache;
-
- // Add an item with a non-existent class
- $discovery->getItems()->add($location, [
- 'class' => 'NonExistentClass',
- 'attribute' => new MockDiscoveredAttribute(
- name: WpRestRoute::class,
- class: WpRestRoute::class,
- arguments: ['api/v1', '/test']
- ),
- 'structure' => new MockDiscoveredClass(
- name: 'NonExistentClass',
- namespace: '',
- file: '/test/NonExistentClass.php'
- ),
- 'reflection_cache' => $reflectionCache,
- ]);
-
- // Should not throw, just log errors
- expect(fn () => $discovery->apply())->not->toThrow(Exception::class);
- });
-
- test('returns correct identifier', function (): void {
- $discovery = new WpRestDiscovery;
- expect($discovery->getIdentifier())->toBe('wp_rest_routes');
- });
-
- test('wrapper system works with non-Attributable classes', function (): void {
- // Test the core wrapper functionality separately
- $className = 'TestDocumentAPI';
- $namespace = 'api/v1';
- $route = '/documents/(?P\\d+)';
-
- // Create wrapper like WpRestDiscovery does
- $wrapper = new class($className, $namespace, $route) implements Attributable
- {
- private mixed $realInstance = null;
-
- public function __construct(
- private readonly string $className,
- public readonly string $namespace,
- public readonly string $route,
- public readonly ?string $classPermission = null
- ) {}
-
- public function getRealInstance(): mixed
- {
- if ($this->realInstance === null) {
- $reflectionClass = new ReflectionClass($this->className);
- if ($reflectionClass->isInstantiable()) {
- $this->realInstance = $reflectionClass->newInstance();
- }
- }
-
- return $this->realInstance;
- }
- };
-
- expect($wrapper)->toBeInstanceOf(Attributable::class);
- expect($wrapper->namespace)->toBe($namespace);
- expect($wrapper->route)->toBe($route);
- expect($wrapper->getRealInstance())->toBeInstanceOf(TestDocumentAPI::class);
- });
-});
diff --git a/tests/e2e/fixtures/plugins/e2e-features/e2e-features.php b/tests/e2e/fixtures/plugins/e2e-features/e2e-features.php
index abcee5d3..73bd5e59 100644
--- a/tests/e2e/fixtures/plugins/e2e-features/e2e-features.php
+++ b/tests/e2e/fixtures/plugins/e2e-features/e2e-features.php
@@ -15,4 +15,7 @@
exit;
}
+// The site follows APP_ENV (local), and WordPress 7.1 refuses pings outside production: the trackback test needs them.
+add_filter('wp_should_disable_pings_for_environment', '__return_false');
+
pollora_register(ModuleType::Plugin, 'e2e-features', __DIR__);