Skip to content

Scheduled Events ​

Scheduled events let you define cron-based batch operations that target all matching machine instances. Unlike after/every (per-instance timers), schedules query the database to find instances and dispatch events to each of them.

Instance Scope ​

FeatureTargetsHow
endpointsSingle instanceHTTP request includes root_event_id or model ID
schedulesAll matching instancesCron → resolver queries DB → batch dispatch
after/everyPer-instanceTimer fires for ONE instance based on state_entered_at

Design Principle: Definition vs Registration ​

Same pattern as endpoints:

endpoints:  Definition declares WHAT  →  MachineRouter in routes/ registers HOW
schedules:  Definition declares WHAT  →  MachineScheduler in routes/console.php registers WHEN + HOW

Defining Schedules ​

Add a schedules key to MachineDefinition::define():

php
MachineDefinition::define(
    config: [
        'on' => [
            'CHECK_EXPIRY' => ['target' => 'expired', 'guards' => 'isExpiredGuard'],
        ],
        'states' => [...],
    ],
    schedules: [
        // Class-based resolver (recommended)
        'CHECK_EXPIRY' => ExpiredApplicationsResolver::class,

        // Inline resolver (closure)
        'SEND_REMINDER' => fn () => Application::where('approved_at', '<=', now()->subDays(2))
                                                ->pluck('application_mre'),

        // No resolver — auto-detect states from idMap
        'DAILY_REPORT' => null,

        // EventBehavior FQCN as key
        CheckExpiryEvent::class => ExpiredApplicationsResolver::class,
    ],
)

Schedule Entry Values ​

ValueTypeBehavior
ResolverClass::classstringContainer-resolved, DI-supported, returns Collection<string> of root_event_ids
fn () => ...ClosureInline, returns Collection<string> of root_event_ids
nullnullAuto-detect: scan idMap for states handling event, query machine_current_states

Registering Schedules ​

Register in routes/console.php using MachineScheduler:

php
use Tarfinlabs\EventMachine\Scheduling\MachineScheduler;

MachineScheduler::register(ApplicationMachine::class, 'CHECK_EXPIRY')
    ->dailyAt('00:10')
    ->environments(['production', 'staging'])
    ->onOneServer();

MachineScheduler::register() returns Laravel's SchedulingEvent, so all Laravel Scheduler fluent methods are available:

php
MachineScheduler::register(ApplicationMachine::class, 'CHECK_EXPIRY')
    ->dailyAt('00:10')
    ->environments(['production', 'staging'])
    ->countries([Country::TR])                   // custom macro
    ->sentryMonitor('expire-older-applications') // 3rd party
    ->onOneServer()
    ->withoutOverlapping()
    ->runInBackground();

Multiple Schedules ​

php
MachineScheduler::register(ApplicationMachine::class, 'CHECK_EXPIRY')
    ->dailyAt('00:10')
    ->onOneServer();

MachineScheduler::register(ApplicationMachine::class, 'SEND_REMINDER_2D')
    ->dailyAt('11:00')
    ->environments(['production']);

MachineScheduler::register(ApplicationMachine::class, 'SEND_REMINDER_5D')
    ->dailyAt('11:00')
    ->environments(['production']);

ScheduleResolver ​

A resolver returns Collection<string> of root_event_ids. It owns the model knowledge — EventMachine receives only IDs.

php
use Tarfinlabs\EventMachine\Contracts\ScheduleResolver;

class ExpiredApplicationsResolver implements ScheduleResolver
{
    public function __construct(
        private readonly ApplicationConfig $config,  // constructor DI
    ) {}

    public function __invoke(): \Illuminate\Support\Collection
    {
        return Application::query()
            ->where('created_at', '<=', now()->subDays($this->config->expiryDays))
            ->whereIn('status', [
                ApplicationStatus::APPROVED,
                ApplicationStatus::WAITING,
            ])
            ->pluck('application_mre');
    }
}

Resolvers are container-resolved, supporting constructor dependency injection.

Naming convention: {Description}Resolver suffix. Examples: ExpiredApplicationsResolver, UnpaidOrdersResolver, ActiveSubscriptionsResolver.

Auto-Detect (Null Resolver) ​

When the resolver is null, the command auto-detects target states:

  1. Scans the definition's idMap for states that handle the event
  2. If a root-level on handler exists, sends to all instances
  3. Queries machine_current_states for matching instances
php
schedules: [
    'DAILY_REPORT' => null,  // auto-detect from idMap
],

Safety Cross-Check ​

Resolver returns root_event_ids from a model query. Models may map to different machine classes conditionally. The command cross-checks with machine_current_states:

php
$rootEventIds = $resolver();

$validIds = MachineCurrentState::whereIn('root_event_id', $rootEventIds)
    ->where('machine_class', $machineClass)
    ->pluck('root_event_id');

This ensures only instances belonging to the correct machine class receive the event.

How It Works ​

Machine Definition                          routes/console.php
  schedules: [                              MachineScheduler::register(X, 'CHECK_EXPIRY')
    'CHECK_EXPIRY' =>                           ->dailyAt('00:10')
      ExpiredApplicationsResolver::class        ->environments(['production'])
  ]                                             ->onOneServer()
        |                                           |
        |  (defines WHAT + WHICH)                   |  (defines WHEN + HOW)
        v                                           v
ProcessScheduledCommand (--class=X --event=CHECK_EXPIRY)
  1. Load definition, find resolver for CHECK_EXPIRY
  2. Has resolver? → run it → get root_event_ids → cross-check machine_class
     No resolver?  → auto-detect states → query machine_current_states
  3. Dispatch SendToMachineJob for each valid instance (Bus::batch)
        |
        v
SendToMachineJob (existing)
  -> Restore machine -> send(event)
  -> Machine's on-transition handles it (guards, actions, transitions)

Edge Cases ​

ScenarioBehavior
Schedule references undefined event typeInvalidScheduleDefinitionException thrown at definition time
Schedule registered but not in definitionCommand warns, nothing dispatched
Schedule in definition but not registeredNever runs — no cron trigger
Same event in schedules AND after/everyIndependent, both valid
Resolver throws exceptionCaught, logged, nothing dispatched
Resolver returns IDs for wrong machine classCross-check filters them out
No instances matchEmpty dispatch, success exit

Infinite Loop Protection ​

Scheduled events are dispatched via queue. Each job is a separate macrostep — the depth counter resets. If a scheduled event triggers an @always loop, only that job fails. Other instances in the batch continue normally.

Comparison with Endpoints ​

Aspectendpointsschedules
Definition keyendpoints: [...]schedules: [...]
RegistrationMachineRouter::register() in routesMachineScheduler::register() in routes/console.php
What definition declaresEvent mappingEvent + resolver
What registration declaresPrefix, middleware, modelCron, environments, macros
Instance scopeSingle (by ID)All matching (by resolver/auto-detect)

Testing Scheduled Events ​

php
BillingMachine::test()
    ->assertHasSchedule('MONTHLY_BILLING')
    ->runSchedule('MONTHLY_BILLING')
    ->assertState('billing_processed');

Full Testing Guide

For comprehensive scheduled event testing patterns, see Scheduled Testing.

Released under the MIT License.