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:
- A Tasks tab appears in the nav (only if the directory exists), right after Home.
- Per task: schedule (from JSON), last-run-ago, return value (type-aware: scalar / array / string), lock status.
- The Control Center is fully agnostic about the
tasksresource and the app, it reads purely fromdata/tasks/. Schedule info comes from<name>.json, not via an app route (that would trigger an HTTP response and disrupt the Control Center render).
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.