Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Result Laravel Package

php-standard-library/result

Result type for PHP that captures success or failure as a value, enabling controlled error handling without exceptions. Part of the PHP Standard Library.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and confirm the API surface before you write anything.

composer require php-standard-library/result

The README in this repo is a stub — it contains no API examples at all, just links. The docs live at php-standard-library.dev (Component → Result), and the split package is a read-only mirror of the php-standard-library/php-standard-library monorepo. So your first ten minutes should be spent in vendor/:

grep -rn "function " vendor/php-standard-library/result/src | head -50
grep -rn "final class\|interface " vendor/php-standard-library/result/src

Do this before copying snippets from blog posts. A lot of the Result material circulating for PHP is modelled on Rust (Result::ok(), Result::err(), ->unwrap(), ->match()). The shipped API here is different: namespace Psl\Result, two concrete classes Success and Failure behind Psl\Result\ResultInterface, a functional entry point Psl\Result\wrap(), and the combinators then(), catch(), always(), plus terminators isSucceeded(), isFailed(), getResult(), getException().

2. Your first use case: a service method around a flaky I/O call.

use Psl\Result;
use Psl\Result\ResultInterface;

/** @return ResultInterface<Invoice> */
function charge(Invoice $invoice): ResultInterface
{
    return Result\wrap(fn () => Stripe::charge($invoice));
}

$result = charge($invoice);

if ($result->isSucceeded()) {
    $invoice = $result->getResult();
} else {
    $e = $result->getException();   // Throwable, always
}

3. Where to look next.

  • vendor/php-standard-library/result/src/ResultInterface.php — the whole contract, one screen.
  • The main monorepo release notes (this repo's release/*.md files are one-line stubs that just link there).
  • Psl\Result\ResultException and Psl\Result\AwaitableResult (the async variant, relevant under Octane/Swoole concurrency).

Implementation Patterns

The boundary rule: Result inside, exceptions at the edges

This is the single pattern that makes the package work in Laravel. Domain/service layers return ResultInterface; controllers, jobs, commands, and middleware convert to the framework's native error channel. Laravel's Handler::render(), Telescope, Sentry, failed_jobs, and retry() all key off thrown exceptions — nothing in the framework knows about a Failure.

// app/Services/OrderService.php
/** @return ResultInterface<Order> */
public function place(PlaceOrder $dto): ResultInterface
{
    return Result\wrap(fn () => Order::createFrom($dto));
}

// app/Http/Controllers/OrderController.php
public function store(PlaceOrderRequest $request): JsonResponse
{
    $result = $this->orders->place(PlaceOrder::fromRequest($request));

    if ($result->isFailed()) {
        $e = $result->getException();

        throw $e instanceof OutOfStock
            ? ValidationException::withMessages(['sku' => $e->getMessage()])
            : $e; // let the handler report genuine errors
    }

    return OrderResource::make($result->getResult())->response()->setStatusCode(201);
}

Pipelines with then() / catch()

then() runs only on success and passes failures straight through; catch() recovers a specific exception class and turns it into a success value. That is your flatMap + recover.

return Result\wrap(fn () => $this->reserve($cart))
    ->then(fn (Reservation $r) => $this->pay($r))
    ->then(fn (Payment $p) => $this->ship($p))
    ->catch(PaymentDeclined::class, fn (PaymentDeclined $e) => $this->releaseReservation($e))
    ->always(fn () => Log::info('checkout pipeline finished'));

Model errors as exception classes, not strings

Failure carries a Throwable, full stop. There is no Result<User, ValidationError[]> with an arbitrary payload. So the way you get typed, matchable errors is to define domain exceptions you never actually throw:

final class OutOfStock extends RuntimeException {}
final class PaymentDeclined extends RuntimeException {}

$result->catch(OutOfStock::class, fn (OutOfStock $e) => $this->suggestAlternatives($e));

This is also why the captured stack trace survives — a real win for logging compared to string error codes.

Queued jobs

public function handle(PaymentService $payments): void
{
    $result = $payments->charge($this->orderId);

    if ($result->isFailed()) {
        $this->fail($result->getException()); // keeps retries / failed_jobs working
    }
}

Never just return; on isFailed(). A job that swallows a failure into a value is marked successful — no retry, no backoff, no entry in failed_jobs, no alert.

Database transactions

Do not return a Failure from inside DB::transaction() — Laravel commits when the closure returns normally. Throw inside, wrap outside:

$result = Result\wrap(fn () => DB::transaction(function () use ($dto) {
    $order = Order::create([...]);
    $this->reserveStock($order); // throws OutOfStock on failure
    return $order;
}));

Testing

$result = $this->service->place($dto);

self::assertTrue($result->isFailed());
self::assertInstanceOf(OutOfStock::class, $result->getException());

Or assert the boundary behaviour with $this->expectException(ValidationException::class). Note that assertions on getResult() are only safe after isSucceeded().

Shared helper

Because there is no applicative/collect() helper, most teams add one thin app-level wrapper to standardise the boundary conversion and the JSON error shape ({ok: false, error: {code, message}}), and to fold a list of results into a single failure-or-successes value.


Gotchas and Tips

The README is not documentation. The split repo is a mirror; issues and PRs belong in php-standard-library/php-standard-library. Its release/*.md notes are one-liners pointing at the monorepo, so pin a version (the 6.3.x line) and read the monorepo changelog before upgrading — you will not learn anything from this repo's changelog.

Namespace mismatch with intuition. It is Psl\Result, not PHPStandardLibrary\Result. If you are migrating from the older azjezz/psl package, the Psl\ namespace is unchanged and most existing docs still apply, but the php-standard-library/* split packages are the v6+ line — don't mix both requirements.

getResult() on a Failure throws; getException() on a Success throws. Use isSucceeded()/isFailed() for branching. Psl\Result\ResultException is what you'll typically see surfacing when someone branches wrongly.

wrap() will swallow programming errors. A TypeError from a null property, a bad argument, an Error from a typo — all become Failures that then get logged and handled as if they were expected domain outcomes. Wrap narrowly, around calls whose exceptions you have deliberately decided to model, and let genuine bugs bubble to the framework handler. Likewise, check whether exceptions thrown inside a then() callback are converted to Failure in your installed version — if they are, a mapping bug disappears silently mid-pipeline instead of hitting Sentry.

Exception traces cost memory in loops. Every Failure retains a throwable with a stack trace. In a batch job or ->each() over thousands of rows, wrapping per-row failures and collecting them will grow. Wrap inside the loop (not around it), log-and-discard failures as you go, and don't hoard Failure objects in a collection.

catch() dispatch is by class, first match wins. Order specific exceptions before broad ones; a catch(Throwable::class) first will shadow everything after it.

Laravel's own validation already throws — don't fight it. $request->validate() and FormRequest are exception-based by design and integrate with the redirect/422 behaviour and Inertia. Keep them throwing, and start using Result in the service/domain layer beneath. Converting validation into Result on day one creates a lot of conversion code for little gain.

retry() and DB::transaction() need throws. retry(3, fn () => ...) only retries on a thrown exception. If your closure returns a Failure, retry will happily "succeed" on the first attempt. Wrap inside the retried closure.

Observability gap. Because nothing is thrown, Telescope's Exceptions tab and your Sentry integration stay empty. Add a single logging boundary (middleware or a base controller helper) that logs getException() on failure, otherwise your error rate metric will read zero while things are broken.

Hot paths. Constructing the objects is cheap; capturing the throwable is not. For expected-and-frequent outcomes (e.g. a cache-miss-style lookup per row), prefer plain nullable/boolean returns and reserve Result for the operations where you genuinely want the error typed and composable.

Extension points. ResultInterface is an interface — you can ship your own Failure implementation carrying a non-Throwable payload (a MessageBag, an error code enum) if the Throwable constraint hurts, but you lose wrap(), catch() by class, and the async interop, so it's usually cheaper to encode error kinds as exception subclasses. If you run Octane or PSL's async component, look at AwaitableResult for await()-able results — it composes with the same then/catch vocabulary.

Debugging quickly. dump($result) gives a readable Success/Failure tree; add ->always(fn () => Log::debug('step done')) while bisecting a pipeline, and temporarily convert isFailed() branches to throw $result->getException() to get the original trace back into your usual tooling.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo
stevenay/myanfont
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc