Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
15 changes: 15 additions & 0 deletions src/Infrastructure/Services/ScheduleDiscovery.php
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,21 @@ private function snake(string $value): string
* @param array<mixed> $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<int|string, mixed> $args
*/
private function placeWordPressCron(string $hookName, string $interval, array $args): void
{
try {
$this->engine()->schedule(new Event($hookName, $this->engine()->now(), $interval, $args));
Expand Down
27 changes: 27 additions & 0 deletions tests/Unit/ScheduleDiscoveryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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), []);

Expand Down
Loading