Skip to content

Time-Based Events

Time-based events let you define after (one-shot) and every (recurring) timers directly on transitions. Time is just another event source — the timer auto-triggers an event after a duration or at intervals while the machine stays in a state.

Timer Value Object

Use the Timer class to define durations:

php
use Tarfinlabs\EventMachine\Support\Timer;

Timer::seconds(30)    // 30 seconds
Timer::minutes(5)     // 5 minutes
Timer::hours(6)       // 6 hours
Timer::days(7)        // 7 days
Timer::weeks(2)       // 2 weeks

Duration must be positive. Passing zero or a negative value throws InvalidTimerDefinitionException.

after — One-Shot Timer

"If the machine stays in this state for N time, auto-trigger this event."

php
'awaiting_payment' => [
    'on' => [
        'PAY'           => 'processing',
        'ORDER_EXPIRED' => ['target' => 'cancelled', 'after' => Timer::days(7)],
    ],
],

After 7 days in awaiting_payment, the ORDER_EXPIRED event fires automatically. The machine transitions to cancelled. If the machine leaves awaiting_payment before 7 days (e.g., via PAY), the timer is implicitly cancelled.

One-shot: fires once per state entry. Tracked via machine_timer_fires table.

every — Recurring Timer

"While in this state, auto-trigger this event every N time."

php
'active_subscription' => [
    'on' => [
        'BILLING' => ['actions' => 'processBillingAction', 'every' => Timer::days(30)],
        'CANCEL'  => 'cancelled',
    ],
],

Every 30 days, BILLING fires and runs the billing action. The machine stays in the same state. When the machine leaves (e.g., via CANCEL), the timer stops.

every with max and then

php
'retrying_payment' => [
    'on' => [
        'RETRY_PAYMENT'   => ['actions' => 'retryPaymentAction', 'every' => Timer::hours(6), 'max' => 3, 'then' => 'MAX_RETRIES'],
        'MAX_RETRIES'     => 'payment_failed',
        'PAYMENT_SUCCESS' => 'paid',
    ],
],

After 3 fires, MAX_RETRIES is sent exactly once. The timer stops.

All Transition + Timer Combinations

php
'on' => [
    // 1. Simple target (no timer)
    'PAY' => 'processing',

    // 2. Simple target + after
    'ORDER_EXPIRED' => ['target' => 'cancelled', 'after' => Timer::days(7)],

    // 3. Simple target + every
    'HEARTBEAT' => ['target' => 'checked', 'every' => Timer::hours(1)],

    // 4. Guarded single branch + after
    'ORDER_EXPIRED' => ['target' => 'cancelled', 'guards' => 'isNotPaidGuard', 'after' => Timer::days(7)],

    // 5. Guarded single branch + every
    'BILLING' => ['actions' => 'billingAction', 'guards' => 'isActiveGuard', 'every' => Timer::days(30)],

    // 6. Guarded multi-branch + after (mixed array)
    'ORDER_EXPIRED' => [
        ['target' => 'cancelled', 'guards' => 'isNotPaidGuard'],
        ['target' => 'late_payment'],
        'after' => Timer::days(7),
    ],

    // 7. Guarded multi-branch + every with max/then
    'RETRY' => [
        ['target' => 'paid', 'guards' => 'isPaymentSuccessGuard'],
        ['actions' => 'retryAction'],
        'every' => Timer::hours(6),
        'max'   => 3,
        'then'  => 'MAX_RETRIES',
    ],

    // 8. Actions only + after (no target, stays in state)
    'SEND_REMINDER' => ['actions' => 'sendReminderAction', 'after' => Timer::days(1)],

    // 9. Actions only + every (recurring action, stays in state)
    'CHECK_STATUS' => ['actions' => 'checkStatusAction', 'every' => Timer::hours(6)],

    // 10. Timer on delegation state (with @done/@fail/@timeout)
    // See Inter-Machine Integration section below
],

Testing Timers

php
use Tarfinlabs\EventMachine\Support\Timer;

OrderMachine::test()
    ->send('SUBMIT')
    ->assertState('awaiting_payment')
    ->assertHasTimer('ORDER_EXPIRED')
    ->advanceTimers(Timer::days(7))
    ->assertState('cancelled')
    ->assertTimerFired('ORDER_EXPIRED');

Full Testing Guide

For comprehensive timer testing patterns, see Time-Based Testing.

Testing Timers

advanceTimers() works in-memory and is sufficient for most timer tests. To verify the machine:process-timers sweep command reads from DB and fires correctly, see Recipe: Timer Sweep in Real Environment.

Architecture: Sweep, Not Delayed Jobs

Timer events are processed by a sweep command (machine:process-timers) that runs on a schedule via Laravel Scheduler.

Why Not Delayed Jobs?

ProblemImpact
Redis flush/restartAll delayed jobs lost
AWS SQS max 15min delay7-day delays impossible
Queue worker restartDelayed jobs may be lost

Why Sweep?

AdvantageDescription
Survives restartsCron is independent of queue
No delay limitWorks for any duration
Self-healingMissed timers caught on next sweep
Deployment-friendlyNew timer configs automatically apply to existing instances
PHP-nativeCron is PHP's natural timer mechanism

Registration

Register timer sweeps in routes/console.php for each machine that uses @after or @every timers:

php
use Tarfinlabs\EventMachine\Scheduling\MachineTimer;

MachineTimer::register(OrderMachine::class);          // everyMinute (default)
MachineTimer::register(BillingMachine::class)
    ->everyFiveMinutes()                              // custom frequency
    ->environments(['production', 'staging']);

register() returns Laravel's SchedulingEvent for full fluent chaining (->withoutOverlapping() and ->runInBackground() are applied by default).

How It Works

  1. You register each timer machine in routes/console.php via MachineTimer::register()
  2. Laravel Scheduler runs machine:process-timers --class=X at the configured frequency
  3. Sweep command queries machine_current_states table for instances past deadline
  4. Dispatches SendToMachineJob for eligible instances via Bus::batch

Implicit Cancel

Timers have no explicit cancel. When a machine leaves the state, the sweep simply won't find it anymore — natural cancellation.

No Sliding-Window API — Use Transit States

There is no Timer::slidingOn(...) or "renewable" timer API. By design, self-loops do NOT reset state_entered_at, so a self-loop with an after timer keeps anchoring on the original entry. To reset a deadline on an event, transition through a transit state — see the Renewable Timers pattern.

Timer Configuration

Configure sweep behavior in config/machine.php:

php
'timers' => [
    'batch_size' => 100,                     // instances per query batch
    'backpressure_threshold' => 10000,       // skip sweep if queue exceeds
],

Sweep frequency is set per machine via MachineTimer::register() (default: everyMinute).

Inter-Machine Integration

Timer transitions work naturally with machine delegation:

php
'awaiting_child' => [
    'machine' => PaymentMachine::class,
    '@done'   => 'completed',
    'on' => [
        'REMIND_CHILD' => ['actions' => 'nudgeChildAction', 'every' => Timer::hours(6)],
        'FORCE_CANCEL' => ['target' => 'timed_out', 'after' => Timer::days(7)],
    ],
],
  • Every 6 hours: nudge action uses dispatchTo() to send event to child
  • After 7 days: parent transitions to timed_out, child cancelled via cleanupActiveChildren

@timeout Coexistence

@timeout (child deadline, delayed job) and after/every (state timers, sweep) serve different purposes and can coexist:

php
'processing' => [
    'machine'  => PaymentMachine::class,
    '@done'    => 'completed',
    '@timeout' => ['target' => 'child_timed_out', 'after' => 300],
    'on' => [
        'ALERT' => ['actions' => 'sendAlertAction', 'after' => Timer::days(1)],
    ],
],

Guard Handling

Guards work exactly like standard guarded transitions. after/every fires once regardless of guard result:

php
'on' => [
    // Single-branch guarded
    'ORDER_EXPIRED' => ['target' => 'cancelled', 'guards' => 'isNotPaidGuard', 'after' => Timer::days(7)],

    // Multi-branch guarded (mixed array)
    'ORDER_EXPIRED' => [
        ['target' => 'cancelled', 'guards' => 'isNotPaidGuard'],
        ['target' => 'late_payment'],
        'after' => Timer::days(7),
    ],
],

Artisan Commands

CommandDescription
machine:process-timers --class=XRun timer sweep for a machine class (--class is required)
machine:timer-statusShow timer status for all instances

Transition Key Reference

KeyTypeDescription
afterTimerAuto-trigger after duration (one-shot)
everyTimerAuto-trigger at interval (recurring)
maxintMax fire count (requires every)
thenstringEvent type or EventBehavior FQCN after max reached

Operational Notes

Missed Sweeps

If a timer sweep is missed (deployment, server restart, queue saturation triggering backpressure skip):

  • after timers: Fire as soon as the next sweep runs after the deadline. No events are lost — the deadline check is absolute (state_entered_at <= now - delay).
  • every timers: The next fire happens on the first sweep after last_fired_at + interval. The effective interval becomes configured_interval + missed_sweep_duration. There is no catch-up mechanism — missed intervals are not retroactively fired.

Backpressure

Timer sweeps check queue size before processing. If the queue exceeds the configured threshold (machine.timers.backpressure_threshold, default: 10000), the sweep is skipped entirely to prevent queue saturation. Monitor the Timer sweep skipped warning in your logs.

Infinite Loop Protection

Timer events are dispatched via queue (Bus::batch). Each queued job is a separate macrostep — the recursive transition depth counter resets for each job. If a timer event triggers an @always loop, only that specific job fails with MaxTransitionDepthExceededException. Other timer instances in the same batch are not affected.

  • Scheduled Events — Cron-based batch operations targeting all matching instances (different scope than per-instance timers)

Released under the MIT License.