Skip to content

Conversation

@Yousha
Copy link

@Yousha Yousha commented Dec 15, 2025

This PR adds standard interfaces for type-safe operation results (success/failure) with structured errors.
It solves inconsistent error reporting across libraries while complemeting PHP's type system. Inspired by Rust/Swift Result types, integrates with existing PSRs.

Contracts:

  • ResultInterface
  • ErrorInterface
  • ResultFactoryInterface

Use cases:

  1. API respones with structured errors
  2. Validation and business rule failures
  3. Repository/DAO operations where "not found" is expected
  4. Chained operations withut try-catch nesting

Signed-off-by: Yousha Aleayoub <yousha.a@hotmail.com>
@Yousha Yousha requested a review from a team as a code owner December 15, 2025 14:39
@mathroc
Copy link
Contributor

mathroc commented Dec 15, 2025

Hi @Yousha, thank you for the proposal. As a first step would be to form a Working Group, you can see here how you can proceed : https://www.php-fig.org/bylaws/psr-workflow/

@Yousha
Copy link
Author

Yousha commented Dec 16, 2025

Pre-Draft posted in Google Groups: https://groups.google.com/g/php-fig/c/OpEuvGERM5A/m/NgKT46EVBgAJ

Comment on lines 218 to 221
$result = $validator->validate($input)
->then(fn($validated) => $repository->save($validated))
->then(fn($entity) => $notifier->notifyCreated($entity))
->mapError(fn($error) => new PublicError($error->getMessage()));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an incomplete example. Usually you need to do something with the error, and the error flow is different from the success flow. Do I get the full example right?

$result = $validator->validate($input)
    ->then(fn($validated) => $repository->save($validated))
    ->then(fn($entity) => $notifier->notifyCreated($entity))
    ->mapError(fn($error) => new PublicError($error->getMessage()));

if ($result instanceof PublicError) {
   // handle error page
}

vs usual:

$result = $validator->validate($input);
if ($result->isFailure()) {
    $publicError = new PublicError($result->getError()->getMessage());
    // handle error page
}

$entity = $repository->save($input);
$notifier->notifyCreated($entity);

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes you are right, example is just minimal. Full version:

$result = $validator->validate($input)
    ->then(fn($validated) => $repository->save($validated))
    ->then(fn($entity) => $notifier->notifyCreated($entity))
    ->mapError(fn($error) => new PublicError($error->getMessage()));

if ($result->isFailure()) {
    return $response->error(
        $result->getError()
    );
}

return $response->success(
    $result->getValue()
);

Let me add 2 versions.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But that is not any better. It is functional style just for the sake of it and makes the whole piece less readable without any benefits.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your point is true about readability, this PSR also supports both styles. Use any style you want :-)

Branching/Simple:

$result = $validator->validate($input);

if ($result->isFailure()) {
    return new PublicError($result->getError()->getMessage());
}

$entity = $repository->save($input);
$notifier->notifyCreated($entity);

Or functional chaining:

$result = $validator->validate($input)
    ->then(fn($v) => $repository->save($v))
    ->then(fn($e) => $notifier->notifyCreated($e))
    ->mapError(fn($err) => new PublicError($err->getMessage()));

Note that functional chaining isn't just for style, it prevents invalid states.... since chaining enforces data validity at EACH step, no risk of using unvalidated or unsaved values.

Yousha and others added 12 commits December 17, 2025 10:42
Co-authored-by: Alexander Makarov <sam@rmcreative.ru>
Co-authored-by: Alexander Makarov <sam@rmcreative.ru>
Add Execution rules comment at interface level.
Improve methods doc comments.
Move Backward Compatibility section to meta document.
Add Backward Compatibility section.
Move section to meta document.
Add Error Classification section.
Improve example.
Add Typing and Generics section.
Add group address.
Improve docblocks for PSR-3.
*
* @param array<string, mixed> $context
*/
public function withContext(array $context): self;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure that should be part of the interface

Copy link
Author

@Yousha Yousha Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, should be:
1, can be used for context propagation through app layers (like add req ID in middlewares), 2, it matches PSR-7 withXYZ() pattern, 3, it required for func error transformation (mapError) to preserve context chain.

Minimal example:

$result = myOperation()
    ->mapError(fn($error) => $error->withContext(['step' => 'validation']))
    ->mapError(fn($error) => $error->withContext(['service' => 'user-api']));

// So final error contains both 'step' and 'service' in context.
if ($result->isFailure()) {
    print_r($result->getError()->getContext());
    // ['step' => 'validation', 'service' => 'user-api']
}

Without it, error context is frozen at creation => breaking composability

?

* - `then()` MUST NOT wrap results; it must flatten them.
*
* @template TValue
* @template TError of ErrorInterface
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest allowing to use anything as error types, forcing to have a Throwable is not that great imo (and a bit costly because creating an exception is not as cheap as creating simpler objects)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, allowing “anything” as error types is not generic, it is untyped... a PSR(mine) that allows mixed errors is useless for composition.

That return type allows to use codes, messages, context and avoids mixed & arrays-of-arrays hell.
Errors MUST be plain value objects. (?)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ErrorInterface is not really a plain value object as it extends Throwable

and enforcing ErrorInterface also prevents reusing anything that isn't compatible with this PSR

also, not enforcing anything is not completely untyped, you can still have the template type and benefit from cases where the result do implement ErrorInterface

Copy link
Author

@Yousha Yousha Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, other PSRs:
PSR-3 LoggerInterface expects Throwable in $context['exception']
PSR-14 listeners may receive errors as Throwable
PSR-18 HTTP clients throw exceptions that implement Throwable
...
So if ErrorInterface does not extend Throwable, our result errors can NOT be passed directly to these standards. Or it's wrong?

From my meta document: Integrate with existing PSRs (PSR-3 logging, PSR-14 events)

ErrorInterface is not really a plain value object as it extends Throwable

It is, because no stack trace unless you call getTrace()
AFAIK, in PHP core, stack traces are only captured when an exception is instantiated, NOT merely because a class implements Throwable

class ValidationError implements \Throwable
{
    public function getTrace(): array { return []; }      // <--- zero-cost like oxygen :D

No stack trace is ever generated, no internal PHP exception machinery is triggered, so object is a true plain value object
Only if you extend Exception (or call debug_backtrace()) do you pay the cost

also, not enforcing anything is not completely untyped, you can still have the template type and benefit from cases where the result do implement ErrorInterface

And not enforcing devs to use ErrorInterface = no standard contract
That defeats the entire purpose of a PSR :/

But I'm still okay with you to remove that...
?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, other PSRs:
PSR-3 LoggerInterface expects Throwable in $context['exception']
PSR-14 listeners may receive errors as Throwable
PSR-18 HTTP clients throw exceptions that implement Throwable

well, for PSR-3, it says:

If an Exception object is passed in the context data, it MUST be in the 'exception' key. Logging exceptions is a common pattern and this allows implementors to extract a stack trace from the exception when the log backend supports it. Implementors MUST still verify that the 'exception' key is actually an Exception before using it as such, as it MAY contain anything.

so, if the error type is not an exception you can log it in another field, or even in the exception field if you're feeling adventurous as implementation of logger must check that it's indeed an exception before using it as such

for psr-14, it mentions throwing exceptions, so yeah if you wrap it in a result with try() the error type will be an exception. doesn't need to be forced to be an exception in Result for that to work

and same for psr-18, it's emitting exceptions, not taking results like things as input either

class ValidationError implements \Throwable

you can't do that it's not valid PHP code : https://3v4l.org/ekl3s#vnull
you have to extend an Exception to implement Throwable

Copy link
Author

@Yousha Yousha Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can't do that it's not valid PHP code : https://3v4l.org/ekl3s#vnull

Yes I know, I just wanted to use pseudocode to show what I mean

So remove Throwable from ErrorInterface ? it breaks nothing

Copy link
Contributor

@shadowhand shadowhand left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea of this PSR, but I think it would make a lot more sense to adopt the Either pattern rather than what is described here, which (AFAICT) is not based on any established patterns in programming.

/**
* Represents structured error information.
*/
interface ErrorInterface extends \Throwable
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems problematic to me. One of the stated goals of this PSR is to avoid the overhead of Throwable/Exception, so it doesn't make sense for the error to extend Throwable.

To make it easier to "throw an error" then I would suggest adding a method getException(): Throwable (or getThrowable) to allow for:

throw $error->getException();

Copy link
Author

@Yousha Yousha Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would reject that, because:

1- My goal was "Avoids exception overhead for expected failures", not Throwable.
2- Many PSR-compliant tools (loggers, HTTP clients...) expect Throwable. Returning a non-Throwable error breaks interoperability.

Note that Throwable is a umbrella INTERFACE for all throwables, while exception is a application-level CLASS for errors(which is overused)

And I think toThrowable() duplicates data: every err must also hold or generate a separate Throwable. That's more overhead, not less.

?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Throwable is an interface that can't be implemented directly by PHP code. so you have to extend an existing exception class to implement it
  2. I don't get your point, nothing is preventing to use a Throwable as the error type. it's just weird to have to (and it has an additional cost)

And I think toThrowable() duplicates data: every err must also hold or generate a separate Throwable. That's more overhead, not less.

only if toThrowable() is called, which may not be needed in a lot of cases

and there's still the issue that getCode() in your interface is incompatible with getCode() from Throwable, that's not valid : https://3v4l.org/6bpap#vnull

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad, let's fix it

We have 2 ways:

1:

interface ErrorInterface extends \Throwable
{
    public function getCode(): int | string;
}

2:

interface ErrorInterface extends \Throwable
{
   public function getErrorCode(): string;
}

Which one is more acceptable?

*
* @return array<string, mixed>
*/
public function getContext(): array;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than an array, which has poor type validation, maybe it would make more sense to have this be a value getter? As in:

public function getContext(string $key): mixed;

This would imply another modification:

public function withContext(string $key, mixed $value): self;

While it may be slightly less convenient, this signature avoids array "blobs" that are hard to validate and provides a more structured way to access context. For instance, an error could define context keys as constants:

$minLength = $error->getContext($error::MIN_LENGTH);
$maxLength = $error->getContext($error::MAX_LENGTH);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tbh the validation here is not really better, static analysis tools would already warn if you didn't use string for the keys. not everyone use them but 🤷

and having the context was meant to easily be able to log the error with a PSR LoggerInterface afaict

Comment on lines +110 to +112
* @return TError|null
*/
public function getError(): ?ErrorInterface;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the goal is to avoid null returns, then wouldn't it make more sense for this to be:

Suggested change
* @return TError|null
*/
public function getError(): ?ErrorInterface;
* @return TError
* @throws \BadMethodCallException If the result is a success.
*/
public function getError(): ErrorInterface;

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH, I took this design from Rust Result::err() -> Option<E> and Kotlin Result.exceptionOrNull()
If the Result is a failure/error, they return error stuff... but if the Result is a success/ok, they return null

So my getError() must return null because:

1- It is only valid to call when isFailure() is true
2- Returning null on success prevents consumers to handle a meaningless err object
3- Removing null would violate the tagged union pattern: you must check isFailure() first

Note that this PSR avoids null for operation outcomes, not for accessor methods GUARDED by these.

Kotlin docs: exceptionOrNull() returns the encapsulated Throwable exception if this instance represents failure or null if it is success. This function is a shorthand for fold(onSuccess = { null }, onFailure = { it })

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it stays |null or moves to mixed it adds the possibility of having SuccessResultInterface and ErrorResultInterface where SuccessResultInterface replaces getError with public function getError(): null and isError with public function isError(): false. But that might be overly cumbersome.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replaces getError with public function getError(): null

there's also the option to have : never return types on some methods depending on SuccessResultInterface / ErrorResultInterface

I took advantage of that in my Result implementation attempt : https://github.com/texthtml/maybe/tree/main/src/Result and the result is quite nice, Psalm, PHPStorm and mago can give good insight. for exemple, combined with those annotations: @psalm-assert-if-true Result\Success<T> $this on isSuccess() let the static analysis tool detect dead code, warn when trying to unwrap an error that it'll always throw, etc.

```php
<?php

namespace Psr\Error;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The namespace for ErrorInterface and ResultInterface should be the same, in my opinion:

Suggested change
namespace Psr\Error;
namespace Psr\Result;

Copy link
Author

@Yousha Yousha Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can be and I'm okay by your change, but here is my reason:
For clarity and separation of concerns, to load only Error but no Result, & following PSR conventions...
So future PSRs can depend on Error only, without inheriting Result semantics.

I have added a Namespaces section in meta document: https://github.com/php-fig/fig-standards/pull/1344/files#diff-99b9802e69456918264b6be5f78aa91cffedf996d1fe910b0e817c4d30d9be72R92

So ErrorInterface defined in Psr\Error to allow its reuse independently of ResultInterface... including validation, err transport, and normalization use cases.
And ResultInterface composes an error but does NOT own the error abstraction.

?

Improved doc comments.
Add Namespaces section.
Add Note php-fig#2 for optional factory interface.
Add Note php-fig#3 Either Monad pettern.
@Yousha
Copy link
Author

Yousha commented Dec 17, 2025

I like the idea of this PSR, but I think it would make a lot more sense to adopt the Either pattern rather than what is described here, which (AFAICT) is not based on any established patterns in programming.

@shadowhand
This PSR is the Either pattern: Left=Failure, Right=Success
We use Result naming (like Rust/Swift/Kotlin...) as it's more intuitive for PHP developers.
Also we have same monadic structure: map, flatMap (then), fold (match) etc...

(noted to meta document)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants