Getting started
Install
composer require padosoft/laravel-routines
php artisan vendor:publish --tag=routines-migrations
php artisan migrate
The tick registers itself in Laravel’s scheduler. All it needs is for the scheduler to run:
* * * * * cd /path-to-app && php artisan schedule:run >> /dev/null 2>&1
Nine times out of ten the answer is that the system cron is not running. The /health endpoint says
so in those words rather than making you guess — see Admin API.
1. Write a target
A target is a category of work a routine can start. The core does not know what it runs: a
flow, an agent, a job, a query — the target knows the domain, the core knows when, on whose
behalf, under what ceiling and with what outcome.
use Padosoft\Routines\Contracts\Target\{RoutineTarget, TargetDescriptor, TargetResult, ValidationResult};
use Padosoft\Routines\Contracts\Execution\RoutineExecution;
final class SendDigestTarget implements RoutineTarget
{
// Stable forever: changing it orphans history, which is evidence.
public function type(): string { return 'digest'; }
public function descriptor(): TargetDescriptor
{
return new TargetDescriptor(
label: 'Email digest',
summary: 'Sends the period summary to an address.',
fields: ['email' => ['label' => 'Recipient', 'type' => 'email', 'required' => true]],
actionClasses: ['email.send'], // what the user authorises in the mandate
reportsCost: true,
);
}
public function validate(array $payload): ValidationResult
{
return filter_var($payload['email'] ?? '', FILTER_VALIDATE_EMAIL)
? ValidationResult::valid()
: ValidationResult::invalid(['email' => ['That is not a valid address.']]);
}
public function fire(RoutineExecution $execution): TargetResult
{
Mail::to($execution->payload('email'))->send(
new Digest($execution->scheduledFor, $execution->timezone, $execution->idempotencyKey)
);
return TargetResult::succeeded('Digest sent.', cost: 0.001);
}
}
Register it from your service provider:
$this->app->make(TargetRegistry::class)->register(new SendDigestTarget);
It arrives in $execution->idempotencyKey and is stable across retries. A key generated inside
the target would be different on every attempt — exactly the bug the key exists to prevent.
2. Create a routine
$routine = app(RoutineManager::class)->create([
'owner' => 'user:'.$user->id,
'name' => 'Morning digest',
'target_type' => 'digest',
'target_payload' => ['email' => $user->email],
'trigger_kind' => 'cron',
'cron' => '0 6 * * 1-5',
'timezone' => 'Europe/Rome', // THEIR 6am, not UTC's
'budget_per_run' => 0.50,
'max_attempts' => 3,
]);
The payload is validated now, while a human is looking at the form. A broken payload discovered
at the first 3am fire is a silent failure inside a log nobody reads.
3. Check what it will actually do
app(RoutineManager::class)->preview($routine, 5);
// ["2026-09-01 06:00", "2026-09-02 06:00", …] — in the owner's timezone
This is the single most useful thing in the package. 0 6 * * 1-5 tells nobody anything; five dates
written out show immediately that the time is off by a timezone — before the routine runs that way
for a month.
Commands
php artisan routines:tick --limit=100 # one pass (normally the scheduler calls it)
php artisan routines:list --status=active