Skip to content

Schedule recurring WordPress tasks in Pollora with PHP attributes: the Every enum, custom intervals, hook names and arguments, without WP-Cron boilerplate.

Pollora schedules tasks for WordPress as code you can read: a #[Schedule] attribute on a method, or a fluent API when the event depends on a condition or a computed value. One engine, the pollora/schedule package, stands behind both, and a driver decides where events are kept and who runs them: WP-Cron (the default), Action Scheduler, or your Laravel queue.

To schedule a recurring task, simply add the Schedule attribute to your method:

use Pollora\Attributes\Schedule;
class Maintenance
{
#[Schedule('daily')]
public function cleanupDatabase(): void
{
// This method will run once per day
// The hook name will be: maintenance_cleanup_database
}
}

The framework provides an Every enum for type-safe scheduling with predefined intervals:

use Pollora\Attributes\Schedule;
use Pollora\Schedule\Every;
class Maintenance
{
#[Schedule(Every::HOUR)]
public function pingSlack(): void
{
// Runs every hour
}
#[Schedule(Every::DAY)]
public function dailyBackup(): void
{
// Runs once per day
}
#[Schedule(Every::WEEK)]
public function weeklyReport(): void
{
// Runs once per week
}
#[Schedule(Every::MONTH)]
public function monthlyCleanup(): void
{
// Runs once per month (custom schedule automatically registered)
}
#[Schedule(Every::YEAR)]
public function yearlyArchive(): void
{
// Runs once per year (custom schedule automatically registered)
}
}
  • Every::HOUR - Runs once every hour (uses WordPress hourly)
  • Every::TWICE_DAILY - Runs twice per day (uses WordPress twicedaily)
  • Every::DAY - Runs once per day (uses WordPress daily)
  • Every::WEEK - Runs once per week (uses WordPress weekly)
  • Every::MONTH - Runs once per month (automatically creates custom schedule)
  • Every::YEAR - Runs once per year (automatically creates custom schedule)

WordPress provides several default scheduling intervals that you can still use with strings:

  • hourly - Runs once every hour
  • twicedaily - Runs twice per day
  • daily - Runs once per day
  • weekly - Runs once per week

For precise timing control, use the Interval class which provides a fluent API:

use Pollora\Attributes\Schedule;
use Pollora\Schedule\Interval;
class Maintenance
{
#[Schedule(new Interval(hours: 3, minutes: 30))]
public function checkFeeds(): void
{
// Runs every 3 hours and 30 minutes
}
#[Schedule(new Interval(
days: 1,
hours: 12,
minutes: 15,
display: 'Daily plus 12h15m'
))]
public function complexTask(): void
{
// Runs every 1 day, 12 hours, and 15 minutes
}
#[Schedule(new Interval(weeks: 2, display: 'Bi-weekly'))]
public function biweeklyTask(): void
{
// Runs every 2 weeks
}
}
  • seconds: int = 0 - Additional seconds
  • minutes: int = 0 - Additional minutes
  • hours: int = 0 - Additional hours
  • days: int = 0 - Additional days
  • weeks: int = 0 - Additional weeks
  • display: string = 'Custom schedule' - Human-readable description

For backward compatibility, you can still create custom schedules using arrays:

class Maintenance
{
#[Schedule(
['interval' => 3600 * 6, 'display' => '6 hours']
)]
public function performCheck(): void
{
// Runs every 6 hours
}
}

The custom schedule array requires:

  • interval: Time in seconds between executions
  • display: Human-readable name for the schedule

By default, the hook name is generated from the class and method names, but you can specify a custom hook name:

use Pollora\Schedule\Every;
use Pollora\Schedule\Interval;
class Maintenance
{
// With string schedule
#[Schedule('hourly', hook: 'custom_maintenance_hook')]
public function performTask(): void
{
// Uses 'custom_maintenance_hook' instead of 'maintenance_perform_task'
}
// With Every enum
#[Schedule(Every::DAY, hook: 'daily_cleanup')]
public function cleanup(): void
{
// Uses 'daily_cleanup' hook name
}
// With Interval class
#[Schedule(new Interval(hours: 6), hook: 'six_hourly_check')]
public function checkSystem(): void
{
// Uses 'six_hourly_check' hook name
}
}

You can pass additional arguments to your scheduled function with any schedule type:

use Pollora\Schedule\Every;
use Pollora\Schedule\Interval;
class Maintenance
{
// With string schedule
#[Schedule(
'daily',
hook: 'cleanup_task',
args: ['type' => 'full', 'force' => true]
)]
public function cleanup(array $args): void
{
$type = $args['type']; // 'full'
$force = $args['force']; // true
// Perform cleanup based on arguments
}
// With Every enum and arguments
#[Schedule(Every::DAY, hook: 'custom_hook', args: ['type' => 'full'])]
public function updateIndex(array $args): void
{
// Process with arguments
}
// With Interval class and arguments
#[Schedule(
new Interval(hours: 2, minutes: 30),
hook: 'data_sync',
args: ['source' => 'api', 'batch_size' => 100]
)]
public function syncData(array $args): void
{
// Sync data with specified parameters
}
}

Here’s a comprehensive example showing various ways to use the Schedule attribute:

use Pollora\Attributes\Schedule;
use Pollora\Schedule\Every;
use Pollora\Schedule\Interval;
class SystemMaintenance
{
// Using Every enum (recommended)
#[Schedule(Every::DAY)]
public function dailyCleanup(): void
{
// Daily cleanup using enum
}
// Custom interval using Interval class
#[Schedule(new Interval(hours: 4, display: '4 hours'))]
public function systemHealthCheck(): void
{
// Runs every 4 hours
}
// Monthly schedule with custom hook (auto-registered)
#[Schedule(Every::MONTH, hook: 'monthly_reports')]
public function generateReports(): void
{
// Monthly report generation
}
// Complex interval with arguments
#[Schedule(
new Interval(hours: 2, minutes: 30),
hook: 'data_sync',
args: ['type' => 'incremental', 'source' => 'api']
)]
public function syncData(array $args): void
{
// Runs every 2.5 hours with arguments
}
// Legacy string syntax (still supported)
#[Schedule('twicedaily')]
public function legacyTask(): void
{
// Backward compatibility
}
// Legacy array syntax (still supported)
#[Schedule(
['interval' => 3600 * 6, 'display' => '6 hours'],
hook: 'legacy_check'
)]
public function legacyCustomTask(): void
{
// Backward compatibility with arrays
}
}
TypeSyntaxUse CaseCustom Registration
Every EnumEvery::DAYType-safe, predefined intervalsAuto for MONTH/YEAR
Interval Classnew Interval(hours: 2)Precise custom timingAlways
String'daily'WordPress built-insNever
Array['interval' => 3600]Legacy custom intervalsAlways
  1. Hook names are automatically generated in snake_case format if not specified:

    • For a class DatabaseMaintenance with method cleanupOldRecords
    • The generated hook name would be database_maintenance_cleanup_old_records
  2. The Schedule attribute ensures that events are only registered once through automatic discovery.

  3. Custom schedules are automatically registered when using:

    • Every::MONTH and Every::YEAR enum values
    • Interval class instances
    • Legacy array syntax with custom intervals
  4. All syntax variations support the same parameters:

    • hook: string - Custom hook name
    • args: array - Arguments passed to the scheduled method

When the schedule is not known at class-definition time, declare the event in code. Declare it on every request: the handler is attached each time, and the event is scheduled only when none with the same hook and arguments exists.

use Pollora\Schedule\Interval;
use Pollora\Schedule\Schedule;
Schedule::event('reports_compile')->daily()->at('02:00')->call([Reports::class, 'compile']);
Schedule::event('cache_warm')->every(new Interval(minutes: 15))->call(WarmCache::class);
Schedule::once('send_digest')->in(minutes: 10)->with(['user' => 42])->call(SendDigest::class);
Schedule::once('close_poll')->at($poll->closesAt)->call([Polls::class, 'close']);
Schedule::cancel('send_digest', ['user' => 42]);
Schedule::next('reports_compile'); // DateTimeImmutable, or null
Schedule::events(); // everything that is scheduled, whoever scheduled it

at('02:00') follows the clock and the timezone of the site. call() takes an invokable class, [Class::class, 'method'], a closure, or the name of a WordPress hook to fire; classes are built through Laravel’s container, so their dependencies are injected.

Schedule::fake();
$checkout->complete($order);
Schedule::assertScheduled('send_receipt', fn (Event $event) => $event->args === ['order' => 12]);
Schedule::assertNotScheduled('send_survey');

Nothing is scheduled and no handler is attached while faking; Schedule::reset() brings the real driver back.

The driver is set in config/schedule.php (publish it with php artisan vendor:publish --tag=pollora-schedule) or with POLLORA_SCHEDULE_DRIVER. It decides where every event goes: #[Schedule], Schedule::event(), and the wp_schedule_event() calls of WordPress, plugins and themes.

DriverWhere events liveWho runs themNeeds
wp-cron (default)The cron optionWP-Cron, requested by a system cronNothing
action-schedulerAction Scheduler, in a group of its own (pollora/schedule)Action Scheduler’s queue runner, itself run by WP-CronA plugin that bundles it (WooCommerce does)
laravelA delayed job per event in the application’s queueA queue workerA queue connection that accepts delays, and php artisan queue:work
config/schedule.php
'driver' => env('POLLORA_SCHEDULE_DRIVER', 'wp-cron'),
// hooks that stay on WP-Cron whatever the driver
'protected_hooks' => [],

A driver that is not available (Action Scheduler without a plugin that bundles it, a sync queue) leaves every event on WP-Cron, and pollora:doctor says so. Action Scheduler’s own queue runner always stays on WP-Cron: handed to Action Scheduler, it would never run.

Whatever the driver, the cron option lists every scheduled event, with a driver key on the entries another driver keeps. WordPress reads it without a query, so wp_next_scheduled(), Site Health, WP Crontrol and Query Monitor see the events, and WP-Cron never runs one it does not own. A plugin that rewrites the whole array still works: removing or moving an entry reaches the driver that keeps it.

POLLORA_SCHEDULE_DRIVER=laravel
Terminal window
php artisan queue:work

Each event becomes a job delayed until it is due, in the application’s queue (database, Redis, Horizon, SQS). There is no table of Pollora’s own and no schedule:run: a worker is all that runs the events. Without one, none of them fires, and pollora:doctor reports events more than ten minutes late.

config/schedule.php
'laravel' => [
'connection' => env('POLLORA_SCHEDULE_QUEUE_CONNECTION'), // null: the default connection
'queue' => env('POLLORA_SCHEDULE_QUEUE'), // null: the connection's default queue
'max_delay' => env('POLLORA_SCHEDULE_MAX_DELAY'), // null: 900 s on SQS, no limit elsewhere
'late_after' => (int) env('POLLORA_SCHEDULE_LATE_AFTER', 600),
],

How it behaves:

  • The cron option is the record. Cancelling an event removes its entry; the job still wakes, finds no entry (or one with another identifier or time), and stops. Nothing reads the queue.

  • Recurring events queue their next occurrence once they ran, or failed for good: never in the past, so a worker that was down does not replay every missed run.

  • Retries. A job is tried three times (30 s, then 5 min apart). After that the failure lands in failed_jobs as usual, a recurring event goes on, and a single event leaves the schedule. pollora:doctor reports failures of the last week.

  • Delays longer than the connection allows (15 minutes on SQS) are chained: the job wakes, sees it is early, and waits again.

  • Lost jobs. A flushed Redis or a purged queue loses jobs. As a request ends, at most every five minutes, Pollora checks the index, and an event more than late_after seconds past its time is queued again with the same identifier, so it never runs twice. Run the check by hand with:

    Terminal window
    php artisan pollora:schedule:recover # --dry-run lists, --network covers every site
  • Multisite. The queue is shared by the network, with a single worker. Each job runs in the site that scheduled the event, whose cron option is its index.

wp cron event run --due-now is WP-CLI’s manual command: it runs every due event, whoever keeps it, and takes it off the schedule; the job that wakes later stops.

Terminal window
php artisan pollora:schedule:list # --type=once|recurring, --driver=laravel, --site=2, --network, --json
wp cron event list --fields=hook,type,driver # WP-CLI, with type and driver columns and the same filters

Both read the cron option only: no query reaches Action Scheduler or the queue. A driver column reads wp-cron (protected) for a hook that stays on WP-Cron on purpose, (fallback) when the driver is not available, and (to migrate) for an event a former driver still keeps.

Changing POLLORA_SCHEDULE_DRIVER sends new events to the new driver. Events already scheduled stay where they are, and keep running there, until you move them:

Terminal window
php artisan pollora:schedule:migrate --dry-run # what would move
php artisan pollora:schedule:migrate # from WP-Cron to the driver of the site
php artisan pollora:schedule:migrate --from=laravel --to=wp-cron # and back

An event is created in the target before it leaves the source, so a crash duplicates it instead of losing it, and running the command again does not create it twice. pollora:doctor, Site Health and an administration notice remind you while events are left to move.

pollora:doctor and Tools › Site Health (check Scheduled events) report a driver that does not exist or is not available, events a former driver still keeps, late events when the laravel driver has no worker, and a scheduled event that failed for good.

The option wordpress.use_laravel_scheduler and the bridge behind it are removed, without a deprecation period: Pollora\Schedule\Scheduler, SchedulerInterface, Events\*, Jobs\JobDispatcher, the pre_schedule_event and pre_option_cron filters it installed, and the wp_events table. Setting the option no longer does anything.

To keep WP-Cron events on Laravel’s queue:

  1. Set POLLORA_SCHEDULE_DRIVER=laravel.
  2. Run a worker (php artisan queue:work) in place of schedule:run.
  3. Let #[Schedule] and your own code schedule their events again, then move what WP-Cron still holds with php artisan pollora:schedule:migrate.
  4. Drop the old table: DROP TABLE <prefix>wp_events, with the prefix of your database connection. Events that sat in it are not moved.

The engine runs without Laravel, in a plugin or a Bedrock site: see pollora/schedule.