A principled effect runtime for PHP. Effect makes side effects explicit, ensures resource safety, enables structured concurrency, and supports predictable testing without becoming a framework.
- Pure
Effectdescriptions (no side effects during construction) - Total
Exitresults (success, failure, die, interrupted) - Fiber-based concurrency with scoped lifetimes
- Resource safety with LIFO finalizers
- Environment injection via
ContextandLayer - Deterministic time with
ClockandTestClock - Prelude value types:
Option,Either
Early development. Breaking changes are allowed before 1.0.
composer require pjendrusik/effect<?php
declare(strict_types=1);
use EffectPHP\Effect\Effect;
use EffectPHP\Runtime\Runtime;
use EffectPHP\Runtime\Scheduler;
final class InlineScheduler implements Scheduler
{
public function schedule(callable $task, int $delayMillis = 0): void
{
$task();
}
public function run(): void
{
}
}
$runtime = new Runtime(new InlineScheduler());
$program = Effect::succeed('hello')
->map(fn (string $v) => $v . ' world');
$exit = $runtime->unsafeRunExit($program);
if ($exit->isSuccess()) {
echo $exit->value(); // hello world
}Creating an Effect never executes side effects. Side effects happen only when the Runtime evaluates the effect.
Effect construction ──> no side effects
│
▼
Runtime::unsafeRunExit(effect)
│
▼
Callable runs ──> Exit::success | Exit::failure | Exit::die | Exit::interrupted
$effect = Effect::attempt(fn () => file_get_contents('data.txt'));
// Nothing executed yet.
$exit = $runtime->unsafeRunExit($effect);
// The callable runs here. Any Throwable becomes Exit::die(...).If you throw a Throwable inside Effect::attempt, it is captured as a defect.
If you want an expected/domain error, use Effect::fail($error) so it becomes Exit::failure($error).
Public types live under the EffectPHP\ namespace.
Effect is an immutable description of a computation. Constructing an effect
MUST NOT perform side effects.
$effect = Effect::attempt(fn () => file_get_contents('data.txt'));
// Nothing happens yet. Only a description is created.Runtime interprets an Effect and returns a total Exit.
$exit = $runtime->unsafeRunExit($effect);Every execution produces one of:
Exit::success($value)Exit::failure($error)Exit::die(Throwable $t)Exit::interrupted()
For non-success results, Exit also carries a structured Cause via
$exit->cause().
When effects run in parallel (e.g., zipPar), failures can be combined into a
single Cause::both(...) for richer diagnostics.
Cause describes why a non-success exit occurred and can compose failures.
Context is an immutable environment map keyed by service id. Layer builds
and composes services with lifecycle management.
use EffectPHP\Layer\Layer;
$layer = Layer::fromEffect(Logger::class, Effect::succeed(new Logger()));
$ctx = $runtime->unsafeRun($layer->toContext());
$program = Effect::service(Logger::class)->provide($ctx);Scope controls resource finalization. Finalizers run in LIFO order on all
exits, including interruption.
Fiber represents concurrent execution. Child fibers are scoped by default,
with parent-to-child interruption.
Inject time via Clock and use TestClock for deterministic, virtual time.
Effect models environment R, error E, and success A using PHPDoc
generics for static analysis (Psalm/PHPStan). These are not enforced at runtime.
/**
* @template R
* @template E
* @template A
*/
final class Effect
{
/**
* @template B
* @return Effect<R,E,B>
*/
public function map(callable $f): self {}
}Effect supports a small DSL on top of Effect:
$program = Effect::gen(function () {
$a = yield Effect::succeed(1);
$b = yield Effect::succeed(2);
return $a + $b;
});$when = Effect::when(true, Effect::succeed('ok'));
$either = Effect::ifElse($flag, Effect::succeed('yes'), Effect::succeed('no'));
$list = Effect::foreach([1, 2, 3], fn (int $n) => Effect::succeed($n * 2));
$retry = Effect::defer(fn () => doWork())->retry(3);
$tap = Effect::succeed('value')
->tap(fn (string $v) => Effect::succeed('log:' . $v));
$fallback = Effect::fail('no')->orElse(Effect::succeed('fallback'));
$repeat = Effect::succeed('x')->repeat(3);
$collect = Effect::collectAll([
Effect::succeed(1),
Effect::succeed(2),
]);
$bracket = Effect::acquireRelease(
acquire: Effect::attempt(fn () => fopen('php://temp', 'w+')),
use: fn ($h) => Effect::succeed($h),
release: fn ($h) => Effect::attempt(fn () => fclose($h))
);
$span = Effect::span('checkout', ['orderId' => 123],
Effect::log('processing')
);
$onExit = Effect::succeed('ok')->onExit(
fn (EffectPHP\Exit\Exit $exit) => Effect::log('done', ['status' => $exit->tag()])
);
$forked = Effect::forkScoped(Effect::succeed('ok'))
->flatMap(fn ($fiber) => Effect::join($fiber));See examples/run/dsl.php for a runnable example.
See the examples directory for runnable sketches:
examples/resource-management.mdexamples/environment-layer.mdexamples/parallel-cancel.mdexamples/retry-timeout.mdexamples/testclock.mdexamples/rea-typing.mdexamples/domain-errors.mdexamples/run/combinators.phpexamples/run/concurrency.phpexamples/run/interruption.phpexamples/run/dsl.phpexamples/run/reactphp.phpexamples/run/zippar.phpexamples/run/test-scheduler.phpexamples/run/logging.phpexamples/run/fibers.php
Effects can carry structured logging and spans.
Guide: docs/logging.md
| Pattern | Use when | Effect result | Error location | Recovery style |
|---------|----------|---------------|----------------|----------------|
| fail(...) + catchAll | You want domain errors to short-circuit | failure | Exit::failure | catchAll/mapError |
| Either in failure channel | You need rich error info but still use failure channel | failure | Exit::failure(Either) | fold inside catchAll |
| Either in success channel | You want to avoid failure channel entirely | success | Either::Left inside value | map/fold in success path |
Normal PHP execution
- Side effects happen immediately when code runs.
- Errors mix thrown exceptions and return values.
- Cleanup is manual and easy to miss on failure.
- Concurrency and cancellation are ad-hoc.
Effect execution
- Side effects are described and only run by
Runtime. - Every run yields a total
Exitresult. - Finalizers run in LIFO order on all exit paths.
- Fibers are scoped with parent-to-child interruption.
Frameworks handle lifecycle and DI. Effect improves the inside:
- Pure business logic: build effects in services/controllers, run at boundary.
- Predictable errors: domain failures don’t leak as exceptions.
- Deterministic tests: use
TestClockwithout framework changes. - Resource safety:
Scopeguarantees cleanup in services. - Structured concurrency: safe parallel work in commands or jobs.
Effect ships an optional Symfony bundle for the latest LTS (6.4).
Integration guide: docs/symfony.md
Use Effect with a ReactPHP event loop via ReactScheduler.
Integration guide: docs/reactphp.md
Register the bundle:
// config/bundles.php
return [
// ...
EffectPHP\Interop\Symfony\EffectBundle::class => ['all' => true],
];Default services:
effect.runtime(public)effect.scheduler(private, immediate)effect.clock(private, system clock)effect.layer_registry(public)
Tag any Layer service with effect.layer to include it in the registry:
services:
App\MyLayer:
tags: ['effect.layer']Override service IDs via configuration:
effect:
scheduler_service: 'app.custom_scheduler'
clock_service: 'app.custom_clock'
runtime_service: 'app.custom_runtime'
layer_registry_service: 'app.layer_registry'- Explicit effects and total results
- Resource safety and structured concurrency
- Deterministic testing with virtual time
- PHP-idiomatic public APIs
- Framework replacement
- Language-level R/E/A generics or runtime-enforced type checks
Apache-2.0