diff --git a/CHANGELOG.md b/CHANGELOG.md index 781ec14..f79a2e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- A `#[Schedule]` method in the application's own classes (`app/`) is scheduled: its discovery is applied while Laravel boots, before WordPress has loaded its cron functions, so the event was never placed and the error was lost. The placement now waits for `init` when it comes that early. + ## [0.1.3] - 2026-10-13 What a driver with no store of its own (a queue of deferred jobs) needs. `ScheduleDriver` is unchanged. diff --git a/src/Infrastructure/Services/ScheduleDiscovery.php b/src/Infrastructure/Services/ScheduleDiscovery.php index 50b8383..1a2ec4b 100644 --- a/src/Infrastructure/Services/ScheduleDiscovery.php +++ b/src/Infrastructure/Services/ScheduleDiscovery.php @@ -179,6 +179,21 @@ private function snake(string $value): string * @param array $args Optional arguments to pass to the hook */ private function scheduleWordPressCron(string $hookName, string $interval, array $args = []): void + { + // The discovery of the application's own classes is applied while Laravel boots, before WordPress has loaded its cron functions: wait for 'init', which runs before any cron is spawned + if (function_exists('did_action') && did_action('init') === 0) { + add_action('init', fn () => $this->placeWordPressCron($hookName, $interval, $args), 0); + + return; + } + + $this->placeWordPressCron($hookName, $interval, $args); + } + + /** + * @param array $args + */ + private function placeWordPressCron(string $hookName, string $interval, array $args): void { try { $this->engine()->schedule(new Event($hookName, $this->engine()->now(), $interval, $args)); diff --git a/tests/Unit/ScheduleDiscoveryTest.php b/tests/Unit/ScheduleDiscoveryTest.php index e1799b3..85ce25b 100644 --- a/tests/Unit/ScheduleDiscoveryTest.php +++ b/tests/Unit/ScheduleDiscoveryTest.php @@ -59,6 +59,7 @@ public function refresh(): void return true; }); + Functions\when('did_action')->justReturn(1); Functions\when('wp_next_scheduled')->justReturn(false); Functions\when('wp_schedule_event')->alias(function (int $timestamp, string $recurrence, string $hook, array $args = []): bool { $this->events[$hook] = [$recurrence, $args]; @@ -78,6 +79,32 @@ public function refresh(): void ->and($this->events['nightly_reports_archive_old_reports'])->toBe(['every_month', []]); }); +it('waits for init when the discovery is applied before WordPress has loaded its cron functions', function (): void { + Functions\when('did_action')->justReturn(0); + $this->events = []; + $onInit = []; + Functions\when('add_action')->alias(function (string $hook, callable $callback) use (&$onInit): bool { + if ($hook === 'init') { + $onInit[] = $callback; + } + + return true; + }); + + $discovery = new ScheduleDiscovery; + $discovery->discover(new DiscoveryLocation('', __DIR__), DiscoveredClass::fromReflection(new ReflectionClass(NightlyReports::class)), new ReflectionCache); + $discovery->apply(); + + expect($this->events)->toBe([]) + ->and($onInit)->toHaveCount(4); + + foreach ($onInit as $callback) { + $callback(); + } + + expect($this->events)->toHaveKeys(['nightly_reports_compile', 'reports_ping', 'nightly_reports_archive_old_reports']); +}); + it('registers the schedules WordPress does not know', function (): void { $schedules = array_reduce($this->filters['cron_schedules'], fn (array $schedules, callable $filter): array => $filter($schedules), []);