15: Tasks

Phlo has a built-in cross-app task runner. Something triggers tasks::run every minute, a system cron entry or the Phlo Daemon; the tasks resource matches declaratively against %app->tasks. No cron syntax in your app, no external scheduler.

15.1: Setup

Three steps.

1. Activate the resource in data/app.json:

{
	"resources": [..., "tasks"]
}

2. Describe your tasks in app.phlo:

prop tasks => arr(
	cleanup: arr(do: 'account::cleanup', every: '5 minutes'),
	poll:    arr(do: fn() => external::pull(), every: 'minute'),
	backup:  arr(do: 'backup::run', daily: '03:00'),
	report:  arr(do: 'report::weekly', weekly: 'monday 09:00'),
)

3. Trigger tasks::run every minute. With the Phlo Daemon, list the app in its host map and the daemon runs tasks::run for you, no cron line at all:

require('./phlo-daemon.js')(3001, php, { '<host>': { app: '<app>/www/app.php', build: false } })

Without the daemon, a system cron entry does the same:

* * * * * php-zts <app>/www/app.php tasks::run

Place it in /etc/cron.d/example-tasks (system, 6 fields incl. user) or via crontab -u <user> (per-user, 5 fields). Either way the schedule itself stays in %app->tasks; the trigger only supplies the minute tick.

15.2: Schedule

Pick exactly one scheduling key per task:

Key Format Example
every: PHP-readable duration string 'minute', '5 minutes', '2 hours', '1 day'
daily: 'HH:MM' '03:00'
weekly: '<weekday> HH:MM' 'monday 09:00'

every: 'minute' (without a leading number) becomes '1 minute' internally. Parsing via strtotime("+$every", 0).

15.3: Callable (`do:`)

The do: field accepts three forms:

Type Example Is called as
Closure fn() => external::pull() directly
'Class::method' 'account::cleanup' account::cleanup()
Resource name 'backup' phlo('backup')

Unlike during a normal request, a task runs outside an HTTP lifecycle: there is no %req, no %session. Write your task so that it is self-contained.

15.4: State on disk (`data/tasks/`)

tasks::run creates data/tasks/ automatically and guards each task with three files:

File Contents When
<name>.last raw unix timestamp Per successful run, for the due check
<name>.json {schedule, return} for the Control Center Per successful run
<name>.lock empty (mtime counts) During a run, TTL 1 hour

Locks prevent a slow task from lapping itself. The TTL is deliberately 1 hour: a failed task is parked until the lock expires; other tasks keep running as usual.

15.5: Error flow

No try/catch in tasks::run. A Throwable bubbles up to Phlo's framework exception handler and writes to data/errors.json, just like build errors do. The Phlo Control Center shows them in the tasks tab.

15.6: Phlo Control Center

The Phlo Control Center detects data/tasks/ automatically:

15.7: Example

prop tasks => arr(
	heartbeat: arr(do: 'app::heartbeat', every: 'minute'),
)

static heartbeat => file_put_contents(data.'heartbeat.log', date('Y-m-d H:i:s').' tasks::run fired'.lf, FILE_APPEND | LOCK_EX)

Cron entry:

* * * * * php-zts <app>/www/app.php tasks::run

Every minute tasks::run is invoked, sees that heartbeat is every: 'minute' and lastRun < 60s ago, and runs app::heartbeat(). The files data/tasks/heartbeat.last, .json and .lock are updated; intermediate ticks skip the task while it is running.

15.8: Scheduling without cron

If the Phlo Daemon is running, it runs tasks::run for you every minute, so you do not need the per-app cron entry. Every app in the daemon's host map gets the minute tick:

require('./phlo-daemon.js')(3001, '/usr/bin/php-zts', {
	'example.com': { app: '/var/www/example/www/app.php', build: false },
})

The daemon ticks tasks::run once a minute; your %app->tasks schedule (the every: on each task) decides what actually fires, exactly like the cron tick. Everything else is identical: the same %app->tasks matching, the same due-check, the same data/tasks/ state. Cron stays the fallback for hosts without a daemon; pick one trigger, not both.

Last updated on 23 August 2026

We use essential cookies to make this site work. With your permission we also use analytics to improve the site.