tarfin-labs/event-machine
Event Machine is a Laravel package for event-driven architecture, offering a clean, structured API for defining and dispatching domain events, wiring up listeners, and keeping business logic decoupled, testable, and easy to extend.
This is a state-machine package for Laravel with two scheduling surfaces: MachineScheduler::register() for transition scheduling and MachineTimer::register() for timer sweeps (after/every timers). Version 9.21.0 is a bug-fix release, but it changes the effective behaviour of every timer machine you already have, so treat it as an upgrade that needs a plan rather than a drop-in patch.
Minimal steps:
^9.21.0 (anything in 9.3.0–9.20.0 has timer sweeps that never ran).routes/console.php:// routes/console.php
use App\Machines\OrderMachine;
use EventMachine\Scheduling\MachineTimer;
MachineTimer::register(OrderMachine::class);
php artisan machine:validate --all to CI.after timer at once.Look first at routes/console.php, not the machine classes. The machine definition tells you timers exist; only the console routes file tells you whether anything ever sweeps them. The most common state of a 9.3–9.20 install is "timers defined, sweep never scheduled, nothing ever fired, nobody noticed."
The canonical first use case is a timeout: OrderMachine sits in awaiting_payment, and an after timer of 30 minutes transitions it to expired. Define the timer on the machine, register the sweep, run machine:validate --all, then watch a real instance expire. If the validation passes and the transition happens, your scheduling wiring is correct end to end.
Always register through MachineTimer::register(), never by hand. It's tempting to write $schedule->command('machine:process-timers --class=App\Machines\OrderMachine')->everyMinute() yourself. Don't. That is exactly the shape that was broken for eighteen minor versions: the scheduler runs the command line through /bin/sh, which eats the backslashes in the unquoted namespaced class, and you get Class "AppMachinesOrderMachine" not found. The registrar now passes --class as an array so the scheduler shell-escapes it. Fluent overrides and defaults are unchanged, so any cadence customisation you already have keeps working.
One registration per timer machine. The sweep command is per-class; there is no "sweep everything" registration that the validator will accept for a specific machine. Keep the list in routes/console.php next to your other scheduled work so it's reviewable in one place, and add a comment linking each line to the machine.
Validate in CI, and validate through artisan. machine:validate --all now fails a timer machine whose sweep is missing or is scheduled with the class unquoted. Wire it as a CI step:
- run: php artisan machine:validate --all
This is the single highest-value habit from this release — it converts "timers silently never fire" into a red build.
Drain-aware scheduling. A sweep processes timers.batch_size timers per timer per run (default 100). If you have a backlog, a once-a-minute sweep drains ~100/minute/class. For a machine with tens of thousands of overdue instances, either raise timers.batch_size temporarily for the drain window or schedule the sweep more aggressively until the backlog clears, then revert. Don't leave the batch size cranked permanently — each sweep is a single job doing that much work.
Test by observing, not by hand-running. Running machine:process-timers --class="App\Machines\OrderMachine" by hand with quotes worked even on the broken versions, which is precisely why this went unnoticed for so long. Treat a manual run as evidence of nothing. Assert instead on php artisan schedule:list containing a runnable sweep for the class, or on a timer actually firing in a feature test with the schedule invoked.
Separate timer sweeps from transition scheduling. MachineScheduler::register() (long-standing, already correct) handles scheduled transitions; MachineTimer::register() handles timer sweeps. They are different concerns with different failure modes; don't collapse them into one registration helper.
The upgrade will fire your entire overdue backlog. This is the headline risk. Every after timer that came due while sweeps were silently broken fires on the first sweep after deploy — an instance that should have expired in May expires on deploy day. Decide per machine whether those events should still act. If not, move instances out of the timer state before deploying 9.21.0 (a one-off migration or admin action), then deploy. An overdue every timer fires once, not once per missed interval — so every timers are much less dangerous than after timers here.
machine:validate will fail on upgrade for machines that were never actually scheduled. That's not a false positive; it's the bug being reported. Expect the CI step to go red on first push and fix it by adding the missing MachineTimer::register() lines, not by relaxing the check.
Run the validator through artisan. It needs routes/console.php loaded to see the schedule. Invoking the validator class directly from a unit test or a custom command that doesn't bootstrap console routes will produce "sweep not scheduled" findings for machines that are scheduled fine. Use $this->artisan('machine:validate', ['--all' => true]) in tests.
Silent failure is the default failure mode. The sweep command's output goes to /dev/null under the scheduler. Any future scheduling misconfiguration will look identical to a healthy system: no errors, no transitions, no timers firing. Two mitigations worth adopting permanently: php artisan schedule:list in your deploy smoke test (confirm the sweep line exists and the class is quoted/escaped), and at least one timer machine whose expiry you actually monitor in production.
Don't trust the docs you read before 9.21.0. Every claim that timer sweeps were auto-registered has been removed — they have not been since 9.3.0. If your team's internal notes or a scaffolded project says otherwise, correct it.
Pin the floor. "eventmachine/...": "^9.21.0" — any lockfile still resolving 9.20.x or below has non-functional timer sweeps, even if composer update "succeeded."
Extension point to remember: the --class handling now matches MachineScheduler::register() (array-passed option, shell-escaped). If you have written custom scheduling wrappers or your own registrar, mirror that array form — passing a pre-joined string is the exact mistake this release fixes, and it will fail the same silent way.
How can I help you explore Laravel packages today?