This library implements a basic effect system in PHP, backed by generators. It’s loosely based on Koka’s effect system, though with some differences.
Effects can be declared by making a class that extends Effect:
use Versary\EffectSystem\{Effect, Handler};
class AddNumbers extends Effect {
public function __construct(public int $a, public int $b) {}
}Handlers can be declared by making a class that extends Handler, and overriding the resume function
class AddNumberHandler extends Handler {
// Effect handled by this Handler
public static $effect = AddNumbers::class;
public function resume(mixed $effect) {
return $effect->a + $effect->b;
}
}Writing functions that use effects is easy. All effects have to be yield-ed up:
function basic() {
$v = yield new AddNumbers(3, 7);
return $v * 2;
}
function test_basic() {
// Wrap `basic` with a handler for `AddNumbers`. No code has run yet here.
$gen = Effect::handle(basic(), new AddNumberHandler);
// Run the function to completion, handling all effects.
$result = Effect::run($gen);
// $result equals `20`
}Handler’s resume function, which we saw above, allows us to continue execution with an effect’s result.
This is enough for most cases, but some times we need more fine-grained control over how an effect is handled.
For this, we have the handle function.
While resume only takes in a mixed $effect parameter, handle takes a mixed $effect and a $resume closure.
This closure is what allows us to continue execution.
This is how AddNumberHandler would look like if written using handle.
class AddNumberHandlerWithHandle extends Handler {
public static $effect = AddNumbers::class;
public function handle(mixed $effect, \Closure $resume) {
// $resume is a generator, so we need to ensure we yield it's values up.
yield from $resume($effect->a + $effect->b);
}
}The power of handle comes from the fact that we can choose how and when to call $resume.
For example, we can choose to not resume at all, and instead return from handle.
This allows us to for example, make a cancellable function:
class Cancel extends Effect {}
class CancelHandler extends Handler {
public static $effect = Cancel::class;
public function handle(mixed $effect, \Closure $resume) {
return 'cancelled';
}
}
$flag = true;
function program() {
$flag = true;
yield from $this->inner();
// this will not get executed
$flag = false;
}
// Function that will `yield` a `Cancel`.
function inner() {
yield new Cancel;
}
function test_cancel() {
$result = Effect::run(Effect::handle(program(), new CancelHandler));
assertEquals('cancelled', $result);
assertTrue($this->flag);
}This is really powerful, since Cancel can be yielded deep within our callstack, without having to manually return up.
If you want to see more examples, check the tests folder.
Normally, effect handlers are not allowed to resume multiple times, which is the biggest difference this library has with an actual effect system implementation such as Koka’s. This comes from a limitation on PHP’s generators, which are not cloneable.
Generators used to be clonable for a very short period of time during PHP 5, but it got removed due to some difficulties ensuring correctness.
The extension folder is a C extension which reintroduces support for cloning generators.
It can be compiled by running make in that directory.
Loading the extension is optional, and everything (except resuming multiple times) works correctly without it.
The extension was made with the help of Claude. If you don’t trust LLMs, I recommend against using it.