承接 iak/result 相关项目开发

从需求分析到上线部署,全程专人跟进,保证项目质量与交付效率

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

iak/result

Composer 安装命令:

composer require iak/result

包简介

A return type for operations that can fail — make every failure visible, typed, and impossible to ignore

README 文档

README

A return type for operations that can fail.

Instead of throwing exceptions for outcomes you expect — the card is expired, the item is out of stock — you return them. The failure becomes part of the method's signature, the caller has to deal with it, and your IDE and static analyser know both outcomes at every step.

Before and after

You're placing an order. It can fail three ways: the kitchen is closed, an item is out of stock, the address is outside the delivery zone.

Before — exceptions

class PlaceOrder
{
    // The signature promises an Order. The three ways this fails
    // are invisible unless you read the whole method.
    public function handle(Cart $cart, Address $address): Order
    {
        if (! $this->kitchen->isOpen()) {
            throw new KitchenClosedException();
        }

        if (! $cart->allItemsAvailable()) {
            throw new OutOfStockException($cart->unavailableItems());
        }

        if (! $this->zones->covers($address)) {
            throw new DeliveryUnavailableException($address);
        }

        return Order::create($cart, $address);
    }
}
public function store(StoreOrderRequest $request, PlaceOrder $placeOrder)
{
    try {
        $order = $placeOrder->handle($request->cart(), $request->address());
    } catch (KitchenClosedException) {
        return back()->withErrors(['order' => 'The kitchen is closed right now.']);
    } catch (OutOfStockException) {
        return back()->withErrors(['order' => 'Some items are out of stock.']);
    } catch (DeliveryUnavailableException) {
        return back()->withErrors(['order' => "We don't deliver to this address yet."]);
    }

    return redirect()->route('orders.show', $order);
}

Three exception classes to write and maintain. Nothing checks that the caller catches them — forget one (or add a fourth failure mode next month) and the customer gets a 500. And every caller has to know what to catch, because the signature won't tell them.

After — results

enum OrderError
{
    case KitchenClosed;
    case OutOfStock;
    case DeliveryUnavailable;

    public function message(): string
    {
        return match ($this) {
            self::KitchenClosed       => 'The kitchen is closed right now.',
            self::OutOfStock          => 'Some items are out of stock.',
            self::DeliveryUnavailable => "We don't deliver to this address yet.",
        };
    }
}

class PlaceOrder
{
    /** @return Result<Order, OrderError> */
    public function handle(Cart $cart, Address $address): Result
    {
        if (! $this->kitchen->isOpen()) {
            return Result::failure(OrderError::KitchenClosed);
        }

        if (! $cart->allItemsAvailable()) {
            return Result::failure(OrderError::OutOfStock);
        }

        if (! $this->zones->covers($address)) {
            return Result::failure(OrderError::DeliveryUnavailable);
        }

        return Result::success(Order::create($cart, $address));
    }
}
public function store(StoreOrderRequest $request, PlaceOrder $placeOrder)
{
    return $placeOrder->handle($request->cart(), $request->address())->match(
        success: fn (Order $order) => redirect()->route('orders.show', $order),
        failure: fn (OrderError $error) => back()->withErrors(['order' => $error->message()]),
    );
}

The signature now declares every outcome. One enum replaces three exception classes, and the error messages live next to the errors. The failure path can't be forgotten — there is no way to get the Order out without going through the result. And when you add a fourth failure mode, the enum's own match tells you exactly which handlers need updating.

Before and after — a multi-step flow

Failures compose. Where exceptions force a try/catch per step:

try {
    $order = $placeOrder->handle($cart, $address);
} catch (OrderException $e) {
    return response()->json(['error' => $e->getMessage()], 422);
}

try {
    $receipt = $chargeCustomer->handle($order, $card);
} catch (PaymentException $e) {
    return response()->json(['error' => $e->getMessage()], 422);
}

return response()->json($receipt);

…results chain, and the first failure falls through to a single handler:

return $placeOrder->handle($cart, $address)
    ->chain(fn (Order $order) => $chargeCustomer->handle($order, $card))
    ->match(
        success: fn (Receipt $receipt) => response()->json($receipt),
        failure: fn (OrderError|PaymentError $error) => response()->json(['error' => $error->message()], 422),
    );

Installation

composer require iak/result

Requires PHP 8.2+. No dependencies — works in any PHP project, Laravel or not.

API

Creating results

Result::success($value = null)

The operation worked, here's the outcome. Call it without arguments when there's nothing meaningful to return:

return Result::success($order);   // Result holding an Order
return Result::success();         // "it worked" — holds null

Result::failure($error)

The operation failed, here's why. The error can be any value — an enum for a fixed set of outcomes, a value object when the caller needs context, a string, or an exception if you already have one:

return Result::failure(OrderError::OutOfStock);
return Result::failure(new ValidationErrors($messages));
return Result::failure($caughtException);

Result::all($results)

Combine a collection of results into one. All successes → one success holding every value, keys preserved. Any failure → the first failure, returned as-is. Accepts any iterable and stops consuming it at the first failure.

$result = Result::all([
    'order'   => $placeOrder->handle($cart, $address),
    'invoice' => $createInvoice->handle($cart),
]);
// success: ['order' => Order, 'invoice' => Invoice] — or the first failure

Checking the outcome

isSuccess() / isFailure()

Guard-style handling, when match() would be too heavy or you want an early return. After the check, extracting is safe — and static analysers know it:

if ($result->isFailure()) {
    return back()->withErrors(['order' => $result->error()->message()]);
}

$order = $result->value(); // guaranteed — no exception possible here

Getting the value out

value()

The success value. On a failure it throws ResultException, so use it after a guard (see above) — or unguarded as a deliberate assertion: "this can't fail here, blame me if it does."

$order = $result->value();

error()

The mirror image: the error value, throwing ResultException on a success. Mostly used after an isFailure() guard, and in tests:

expect($result->error())->toBe(OrderError::OutOfStock);

valueOr($default)

The value, or a fixed fallback when it failed. For when you don't care why it failed:

$eta = $estimateDelivery->handle($address)->valueOr(45); // minutes

valueOrElse($fallback)

Like valueOr(), but the fallback is computed from the error — and only when actually needed:

$eta = $estimateDelivery->handle($address)
    ->valueOrElse(fn (EstimateError $error) => $error->conservativeGuess());

expect($message) / expectError($message)

Like value()/error(), but the exception carries your message — useful when the assertion deserves an explanation for whoever hits it later:

$order = $result->expect('cart was validated in the previous step');

Transforming

map($fn)

Transform the success value without unpacking the result. A failure passes through untouched:

$result->map(fn (Order $order) => $order->total);
// Result<Order, OrderError> → Result<Money, OrderError>

mapError($fn)

Transform the error — typically to translate a low-level error into your domain's language at a boundary:

$gateway->charge($card)
    ->mapError(fn (GatewayError $error) => PaymentError::fromGateway($error));

tap($fn) / tapError($fn)

Run a side effect — logging, metrics, notifications — without touching the result. The result flows through unchanged, and the callback only runs for its variant:

return $placeOrder->handle($cart, $address)
    ->tap(fn (Order $order) => Log::info('order placed', ['id' => $order->id]))
    ->tapError(fn (OrderError $error) => Metrics::increment('orders.rejected'));

Chaining fallible steps

chain($fn)

Pipe the success value into the next operation that can itself fail. The first failure short-circuits everything after it. Error types accumulate, so the final handler is forced to know about every step's failure modes:

return $placeOrder->handle($cart, $address)                          // Result<Order, OrderError>
    ->chain(fn (Order $order) => $charge->handle($order, $card))     // Result<Receipt, OrderError|PaymentError>
    ->chain(fn (Receipt $receipt) => $notify->handle($receipt));     // Result<null, OrderError|PaymentError|NotifyError>

Why chain() and not then()? Promise libraries (Guzzle — and therefore Laravel's Http::async() — and ReactPHP) treat any object with a public then() method as a promise and try to resolve it. A Result named that way would break inside promise pipelines.

orElse($fn)

The recovery counterpart: on failure, try something else that can itself succeed or fail. A success passes through untouched:

$receipt = $chargeCard->handle($order, $card)
    ->orElse(fn (PaymentError $error) => $chargeWallet->handle($order));

Handling both outcomes

match($success, $failure)

Handle both variants in one expression. Both arms are required, so the failure path can't be forgotten:

return $result->match(
    success: fn (Order $order) => redirect()->route('orders.show', $order),
    failure: fn (OrderError $error) => back()->withErrors(['order' => $error->message()]),
);

Good to know

  • ResultException — the only exception the package throws (from value(), error(), expect(), expectError() on the wrong variant). It carries the offending value on ->value, and when the error is itself a Throwable, it's chained as ->getPrevious() so stack traces survive.
  • Immutable — transformations never mutate; they always return a result, never modify one in place.
  • Structural equalityResult::success(1) == Result::success(1) is true.
  • Serializable — a result serializes cleanly as long as the contained value does, so it survives caches and queues.
  • SealedSuccess and Failure are final; isFailure() === false always means success.
  • Prefer isSuccess()/isFailure() over instanceof — static analysers can't carry the value/error types through a bare instanceof, but the methods keep them intact.

Development

composer test      # Pest
composer analyse   # PHPStan, including the type-inference fixtures in types/
composer format    # Pint

License

MIT. See LICENSE.md.

统计信息

  • 总下载量: 0
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 0
  • 点击次数: 1
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-07-08

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固