graham-campbell/result-type
Result Type is a PHP implementation of the result type, maintained by Graham Campbell. It offers a clean way to represent success or failure values instead of exceptions. Install via Composer with graham-campbell/result-type ^1.1; requires PHP 7.2.5-8.6. MIT licensed.
Install. It is a single Composer dependency with no Laravel package machinery:
composer require "graham-campbell/result-type:^1.1"
There is no service provider, no config file, no facade, and nothing to publish. Do not add it to config/app.php; Result is a value object, not a service. If you find yourself wanting to bind it in the container, you are holding it wrong — bind the service that returns it.
Where to look first. The README is a stub (install + license only), so skip it and read the source, which is roughly four small files:
src/Result.php — the abstract base: ok(), error(), try() factories, plus isOk(), isError(), map(), flatMap(), mapError(), flatMapError(), filter(), and the terminal unwrap/match helpers.src/Success.php, src/Error.php — the two concrete variants.composer.json — the real constraint worth knowing: it pulls in phpoption/phpoption (≥1.10 as of v1.2.0), so this is not a zero-dependency leaf.Budget twenty minutes for that reading. It is the whole API surface.
First use case. Pick one service method where failure is an expected outcome — a payment authorization decline, an address-validation rejection, a third-party API returning a business-level "no" — and make that the return type instead of throwing. Not a controller, not a model, not a job:
namespace App\Services\Payments;
use GrahamCampbell\ResultType\Result;
use GrahamCampbell\ResultType\Success;
use GrahamCampbell\ResultType\Error;
final class AuthorizePayment
{
/** @return Result<Charge, PaymentError> */
public function handle(Money $amount, Card $card): Result
{
$response = $this->gateway->authorize($amount, $card);
return $response->declined()
? Result::error(PaymentError::Declined)
: Result::ok(Charge::fromResponse($response));
}
}
Then translate it at the edge, on day one:
$result = app(AuthorizePayment::class)->handle($amount, $card);
if ($result->isError()) {
return response()->json(['error' => $result->error()->value], 402);
}
return new ChargeResource($result->value());
That single vertical slice — service returns Result, controller converts — is the entire first milestone. Everything else is elaboration.
Service-layer return type, framework-edge exception. The load-bearing convention is: Result lives inside the domain/service boundary; exceptions remain the language of HTTP, Eloquent, queues, and middleware. Every Result-returning service needs two adapters written on the same day as the service:
// Result -> HTTP (controller)
// Result -> exception (when a caller insists on throwing)
$result = $service->handle(...);
return $result->isOk()
? $result->value()
: throw new PaymentDeclinedException($result->error());
Composition instead of nested try/catch. This is where the pattern earns its keep. Multi-step workflows chain with map/flatMap, and an error short-circuits the remainder of the chain for free:
return $this->validate($payload) // Result<Validated, Error>
->flatMap(fn ($v) => $this->enrich($v)) // Result<Enriched, Error>
->flatMap(fn ($e) => $this->persist($e)) // Result<Saved, Error>
->map(fn ($s) => new OrderResource($s));
Steps after the failing one never execute, and you wrote no early returns. mapError()/flatMapError() are the mirror image for translating an upstream taxonomy into yours (e.g. SDK error codes → your PaymentError enum).
Wrap throwing SDKs with a small adapter, once. Third-party clients throw. Concentrate the impedance mismatch in exactly one place per integration rather than sprinkling try/catch through the domain:
private function attempt(callable $fn): Result
{
try {
return Result::ok($fn());
} catch (GatewayException $e) {
return Result::error(PaymentError::fromGateway($e->getCode()));
}
}
Guard against catching too broadly here — catching Throwable will swallow TypeError and programming bugs, turning loud failures into silent Error values.
Error taxonomy as a backed enum. On PHP 8.1+, use a string-backed enum for the E side. It gives you ->name, ->value, ::from() at boundaries, and static analysis can actually reason about it. If the team can't articulate who owns the enum and how it evolves, stop the pilot — an ad-hoc zoo of string error codes is worse than exceptions.
Use filter() for validation-style predicates where the rule is a one-liner, and keep FormRequest for HTTP-shaped validation. Result is not a replacement for Laravel's validator; it is for rules that live in the domain and need to return a typed reason rather than a MessageBag.
Terminal handling via match-style callbacks or an explicit guard. The idiomatic exit is a single terminal call per Result. Where no helper fits, isOk()/isError() plus value()/error() is always available. Either way, both branches get written once, at the boundary, in one visible place.
Where not to use it. Do not put Result in controller signatures, FormRequest rules, middleware handle() returns, Eloquent casts/accessors, event listeners, or queued job payloads. Those layers are exception-driven and serialization-dependent; the translation cost exceeds the benefit every time.
Error collides with PHP's global \Error. In a namespace, use GrahamCampbell\ResultType\Error; silently shadows the engine's \Error class. Any catch (Error $e) in that file now catches the wrong thing. Alias it (use GrahamCampbell\ResultType\Error as ResultError;) or keep the import file-scoped and tiny.->value() on an Error and getting a runtime exception — the exact failure mode the pattern claims to prevent. Enforce it mechanically: PHPStan/Larastan or Psalm in CI with a rule that flags ignored Result returns. Without that gate, the convention erodes within a sprint or two.Result<Charge, PaymentError> is erased at runtime. Static analysis is the only thing making the error type meaningful — write the @return Result<…> annotations, don't skip them.Result. It has no __serialize contract you should rely on, cannot cross a queue boundary, and should never be returned as an API response body. Convert to a DTO/HTTP status/exception at the edge. This is the single most common design mistake with these types.instanceof Success. v1.2.0 made Result covariant in its value and error types and widened the factory/transformation signatures; the concrete class you get back is an implementation detail of the factory. Use isOk()/isError() as the guard.try() is not a magic wand. Factory helpers that wrap callables still swallow the exception object into the error side — check what they actually carry before relying on the failure detail for logging.phpoption/phpoption ≥1.10 (raised in v1.2.0). Commit composer.lock, and if you're mid-platform-upgrade, confirm the resolved phpoption version rather than assuming.Result-returning service that only asserts the success path is worse than useless — it certifies coverage that doesn't exist. Make "one assertion per branch" a review checklist item; it's cheaper than static analysis and catches most of it.Result in the next and back again, stack traces become unreadable and error contracts diverge between teams. One convention per layer, written down, with a named owner.How can I help you explore Laravel packages today?