Search by

prabhnoor12 / result-php

Prabhnoo12

A type-safe Result/Either implementation for modern PHP

Package info

github.com/prabhnoor12/result-php

pkg:composer/prabhnoor12/result-php

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v2.0.0 2026-10-02 04:39 UTC

This package is not auto-updated.

Last update: 2026-11-13 03:42:20 UTC


README

A type-safe Result/Either implementation for modern PHP. Handle errors explicitly without exceptions or null checks.

Why?

PHP's error handling is messy. You either throw exceptions (verbose, hard to track) or return null/false (ambiguous, loses context). Result types make errors explicit, composable, and part of your API contract.

Installation

composer require prabhnoor12/result-php

Quick Start

use ResultPHP\Result;

// Return a Result from your functions
function findUser(int $id): Result
{
    $user = User::find($id);
    
    return $user 
        ? Result::success($user)
        : Result::failure('User not found');
}

// Handle both outcomes explicitly
$result = findUser(42);

$result->match(
    onSuccess: fn(User $user) => view('profile', ['user' => $user]),
    onFailure: fn(string $error) => abort(404, $error)
);

// Chain operations
$user = findUser(42)
    ->andThen(fn($user) => validatePermissions($user))
    ->map(fn($user) => $user->toArray())
    ->getOrElse([]);

Core API

Creating Results

// Success
$result = Result::success($value);

// Failure
$result = Result::failure($error);

// From nullable
$result = Result::fromNullable($value, 'Value was null');

// From try/catch
$result = Result::try(fn() => riskyOperation());

// From try/catch with arguments and an optional error mapper
$result = Result::tryWith('file_get_contents', [$path]);
$result = Result::tryWith($callable, $args, fn(\Throwable $e) => "IO error: {$e->getMessage()}");

Handling Results

// Match - handle both cases
$result->match(
    onSuccess: fn($value) => handleSuccess($value),
    onFailure: fn($error) => handleFailure($error)
);

// Get value or default
$value = $result->getOrElse($default);

// Get value or null (no throwing, unlike get())
$value = $result->getOrNull();
$error = $result->getErrorOrNull();

// fold - alias for match
$message = $result->fold(
    onSuccess: fn($user) => "Hello, {$user->name}",
    onFailure: fn($error) => "Error: $error",
);

// Get value or throw
$value = $result->getOrThrow();

// Check state
if ($result->isSuccess()) {
    // ...
}

Transforming Values

// map - transform success value
$result->map(fn($user) => $user->name);

// mapError - transform error value
$result->mapError(fn($error) => strtoupper($error));

// andThen - chain operations that return Results
$result->andThen(fn($user) => validateUser($user));

// tryMap - like map, but a throwing callback becomes a Failure
$result->tryMap(fn($user) => parseConfig($user->raw));

// andThenTry - like andThen, but a throwing callback becomes a Failure
$result->andThenTry(fn($user) => Result::success($user->activate()));

// Both accept an optional error mapper as second argument
$result->tryMap(
    fn($user) => parseConfig($user->raw),
    fn(\Throwable $e) => "Config error: {$e->getMessage()}",
);

// ensure - keep the value only if it satisfies a predicate
$result->ensure(fn($age) => $age >= 18, 'Must be 18 or older');

// mapOr - transform the value or return a default on failure
$name = $result->mapOr(fn($user) => $user->name, 'Guest');

Side Effects

// tap - execute side-effect on success without transforming
$result
    ->tap(fn($user) => Log::info("Processing user: {$user->id}"))
    ->map(fn($user) => $user->toArray());

// tapError - execute side-effect on failure without transforming
$result
    ->tapError(fn($error) => Log::error("Failed: $error"))
    ->getOrElse([]);

Recovery & Fallbacks

// orElse - provide fallback on failure
$result = fetchFromCache()
    ->orElse(fn($error) => fetchFromDatabase());

// Chaining recovery attempts
$result = fetchFromCache()
    ->orElse(fn($e) => fetchFromDatabase())
    ->orElse(fn($e) => fetchFromApi());

Combining Results

// all - combine multiple results
$combined = Result::all([
    'user' => findUser($id),
    'posts' => findPosts($id),
]);

$combined->match(
    onSuccess: fn($data) => view('profile', $data),
    onFailure: fn($error) => abort(404)
);

// zip - combine two results into a tuple
$pair = Result::success(10)
    ->zip(Result::success('hello'));
// Result::success([10, 'hello'])

// partition - split a batch into successes and failures (never fails itself)
$parts = Result::partition([
    'a' => Result::success(1),
    'b' => Result::failure('bad'),
]);
// ['successes' => ['a' => 1], 'failures' => ['b' => 'bad']]

Which combiner to use:

Helper On failure Use when
all First error wins Every input must succeed; fail fast
collect All errors keyed Form validation, batch checks
partition Never fails, splits both Partial success is acceptable

Error Contract

Rules for what throws and what returns a Failure:

// These NEVER throw on failure - they return a Failure instead
$result->map(...); $result->mapError(...); $result->tap(...);
$result->ensure(...); $result->orElse(...); $result->flatten();

// These convert a throwing callback into a Failure
$result->tryMap(...); $result->andThenTry(...);

// These throw LogicException on PROGRAMMING errors (never on domain failures)
$result->get();                    // called on a Failure
$result->getError();               // called on a Success
$result->andThen(fn($v) => 42);    // callback must return a Result

// These throw ResultException to extract the value
$result->getOrThrow();             // chains the original Throwable as previous
$result->unwrap('message');        // custom message, same chaining

Advanced

// flatten - unwrap nested Results
$nested = Result::success(Result::success(42));
$flat = $nested->flatten();
// Result::success(42)

// flatten also handles nested failures
$nested = Result::success(Result::failure('inner error'));
$flat = $nested->flatten();
// Result::failure('inner error')

// unwrap - get value or throw with custom message
$value = $result->unwrap('Expected user to exist');

// expect - alias for unwrap
$value = $result->expect('User should be loaded');

// collect - combine results and gather ALL errors
$combined = Result::collect([
    'name' => Result::success('Alice'),
    'email' => Result::failure('invalid'),
    'age' => Result::failure('too young'),
]);
// Result::failure(['email' => 'invalid', 'age' => 'too young'])

Serialization & Debugging

// Serialize for caching/queues
$serialized = $result->toArray(); // or $result->__serialize()
// ['type' => 'success', 'value' => ...] or ['type' => 'failure', 'error' => ...]

// Deserialize
$result = Result::fromArray($serialized); // or Result::fromSerialized($serialized)

// JSON - Results implement JsonSerializable
$json = json_encode($result);
$result = Result::fromArray(json_decode($json, true));

// Better debugging with var_dump/print_r
var_dump($result);
// Shows: ['type' => 'Success', 'value' => ...] or ['type' => 'Failure', 'error' => ...]

Real-World Examples

Form Validation

function validateEmail(string $email): Result
{
    return filter_var($email, FILTER_VALIDATE_EMAIL)
        ? Result::success($email)
        : Result::failure('Invalid email address');
}

function validateAge(int $age): Result
{
    return $age >= 18
        ? Result::success($age)
        : Result::failure('Must be 18 or older');
}

// Validate and process
$result = Result::success($requestData)
    ->andThen(fn($data) => validateEmail($data['email']))
    ->andThen(fn($email) => validateAge($data['age']))
    ->match(
        onSuccess: fn($data) => createUser($data),
        onFailure: fn($error) => back()->withErrors(['validation' => $error])
    );

API Calls with Fallbacks

function fetchUserData(int $userId): Result
{
    return Result::try(fn() => Http::get("/api/users/{$userId}")->json())
        ->orElse(fn($error) => Result::try(fn() => Cache::get("user_{$userId}")))
        ->mapError(fn($error) => "Failed to fetch user: {$error->getMessage()}")
        ->tap(fn($data) => Log::info("Fetched user {$userId}"))
        ->tapError(fn($error) => Log::error($error));
}

Retries with Backoff

function fetchWithRetry(string $url, int $attempts = 3): Result
{
    $result = Result::try(fn() => Http::get($url)->throw()->json());

    return $result->isSuccess() || $attempts <= 1
        ? $result
        : fetchWithRetry($url, $attempts - 1);
}

// Or declaratively: primary, then fallbacks
$result = Result::try(fn() => primary()->throw())
    ->orElse(fn($e) => Result::try(fn() => secondary()->throw()))
    ->mapError(fn($e) => "All sources failed: {$e->getMessage()}");

Saga (Compensating Transactions)

$result = Result::success($order)
    ->andThen(fn($o) => reserveStock($o))       // Result<Order, string>
    ->andThen(fn($o) => chargePayment($o))      // fails here...
    ->orElse(function ($error) {                // ...so compensate
        releaseStock($order);
        return Result::failure($error);
    });

Database Operations

function createUser(array $data): Result
{
    return Result::try(function() use ($data) {
        return DB::transaction(function() use ($data) {
            $user = User::create($data);
            $user->profile()->create(['bio' => '']);
            return $user;
        });
    })
    ->mapError(fn(\Throwable $e) => "Failed to create user: {$e->getMessage()}");
}

Type Safety

The library is fully typed with PHPStan generics. Your IDE will provide autocomplete and catch type errors:

/** @return Result<User, string> */
function findUser(int $id): Result
{
    // PHPStan knows this returns Result<User, string>
}

$result = findUser(42);
// IDE knows $result is Result<User, string>

$result->map(fn(User $user) => $user->name);
// IDE knows this returns Result<string, string>

Reliability Features

Immutability

All Result operations return new instances. Original results are never modified:

$original = Result::success(10);
$mapped = $original->map(fn($v) => $v * 2);

$original->get(); // Still 10
$mapped->get();   // 20

Short-Circuit Evaluation

Chains stop executing on first failure, preventing unnecessary work:

$result = Result::success(1)
    ->map(fn($v) => expensiveOperation1($v))  // Runs
    ->andThen(fn($v) => Result::failure('stop')) // Stops here
    ->map(fn($v) => expensiveOperation2($v));  // Never runs

Comprehensive Error Handling

Multiple strategies for different use cases:

// Get first error
Result::all([...]);

// Collect all errors
Result::collect([...]);

// Provide fallbacks
$result->orElse(fn($error) => alternative());

// Custom error messages
$result->unwrap('Expected value to exist');

Serialization Support

Safe for caching, queues, and distributed systems:

// Cache results
Cache::put('key', $result->__serialize());

// Retrieve and deserialize
$serialized = Cache::get('key');
$result = Result::fromSerialized($serialized);

Debug-Friendly

Clear output for debugging and logging:

var_dump($result);
// array(2) {
//   ["type"]=> string(7) "Success"
//   ["value"]=> int(42)
// }

Requirements

  • PHP 8.1 or higher
  • No external dependencies

Development

# Install dependencies
composer install

# Run tests
composer test

# Run static analysis
composer analyse

# Run both
composer check

# Mutation testing (requires Xdebug/pcov for coverage)
composer mutation

# Run the suite under PHP 8.1 (requires Docker)
composer test:8.1

Changelog

See CHANGELOG.md for release history and 1.x → 2.0 upgrade notes.

Contributing

See CONTRIBUTING.md for details.

License

MIT License. See LICENSE for details.