Skip to content

pjendrusik/effect

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

9 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

EffectPHP

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 Effect descriptions (no side effects during construction)
  • Total Exit results (success, failure, die, interrupted)
  • Fiber-based concurrency with scoped lifetimes
  • Resource safety with LIFO finalizers
  • Environment injection via Context and Layer
  • Deterministic time with Clock and TestClock
  • Prelude value types: Option, Either

Status

Early development. Breaking changes are allowed before 1.0.

Installation

composer require pjendrusik/effect

Quick Start

<?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
}

Execution Model (No Side Effects on Construction)

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).

Core Concepts

Namespace

Public types live under the EffectPHP\ namespace.

Effect

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

Runtime interprets an Effect and returns a total Exit.

$exit = $runtime->unsafeRunExit($effect);

Exit

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

Cause describes why a non-success exit occurred and can compose failures.

Context / Layer

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

Scope controls resource finalization. Finalizers run in LIFO order on all exits, including interruption.

Fibers

Fiber represents concurrent execution. Child fibers are scoped by default, with parent-to-child interruption.

Clock / TestClock

Inject time via Clock and use TestClock for deterministic, virtual time.

R/E/A Generics (PHPDoc)

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 {}
}

DSL (Generator + Fluent Helpers)

Effect supports a small DSL on top of Effect:

Generator DSL (do-notation)

$program = Effect::gen(function () {
    $a = yield Effect::succeed(1);
    $b = yield Effect::succeed(2);
    return $a + $b;
});

Fluent Helpers

$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.

Examples

See the examples directory for runnable sketches:

  • examples/resource-management.md
  • examples/environment-layer.md
  • examples/parallel-cancel.md
  • examples/retry-timeout.md
  • examples/testclock.md
  • examples/rea-typing.md
  • examples/domain-errors.md
  • examples/run/combinators.php
  • examples/run/concurrency.php
  • examples/run/interruption.php
  • examples/run/dsl.php
  • examples/run/reactphp.php
  • examples/run/zippar.php
  • examples/run/test-scheduler.php
  • examples/run/logging.php
  • examples/run/fibers.php

Logging & Tracing

Effects can carry structured logging and spans.

Guide: docs/logging.md

Error Handling Guide

| 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 vs Effect

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 Exit result.
  • Finalizers run in LIFO order on all exit paths.
  • Fibers are scoped with parent-to-child interruption.

Benefits in Frameworks (Symfony, etc.)

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 TestClock without framework changes.
  • Resource safety: Scope guarantees cleanup in services.
  • Structured concurrency: safe parallel work in commands or jobs.

Symfony Bundle (Interop)

Effect ships an optional Symfony bundle for the latest LTS (6.4).

Integration guide: docs/symfony.md

ReactPHP Interop

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'

Project Goals

  • Explicit effects and total results
  • Resource safety and structured concurrency
  • Deterministic testing with virtual time
  • PHP-idiomatic public APIs

Non-Goals

  • Framework replacement
  • Language-level R/E/A generics or runtime-enforced type checks

License

Apache-2.0

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages