prabhnoor12 / result-php
A type-safe Result/Either implementation for modern PHP
Requires
- php: ^8.1
Requires (Dev)
- infection/infection: ^0.35
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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.