Semantic Exceptions

“I have not failed. I’ve just found 10,000 ways that won’t work”

  —Thomas Edison (1847-1931)

Meaningful Failures

Generic exceptions tell what happened:

catch (Exception $e) {
    echo $e->getMessage();  // "Validation failed"
}

In contrast, semantic exceptions tell why existence is impossible:

catch (SemanticVariableException $e) {
    foreach ($e->getErrors()->exceptions as $exception) {
        echo get_class($exception) . ": " . $exception->getMessage();
        // EmptyNameException: Name cannot be empty
        // InvalidEmailException: Invalid email format
    }
}

Domain Exception Classes

All exceptions extend PHP’s \DomainException. Technical exceptions (RuntimeException, InvalidArgumentException, etc.) are not used. Failures are always expressed as failures with domain meaning. Note that exception classes are plain final class, not readonly—\DomainException is not a readonly class, and PHP does not allow a readonly class to extend a non-readonly one:

final class EmptyNameException extends \DomainException {}

final class InvalidEmailException extends \DomainException
{
    public function __construct(public string $invalidEmail)
    {
        parent::__construct("Invalid email format: {$invalidEmail}");
    }
}

// Age-related existence failures
abstract class AgeException extends \DomainException {}
final class NegativeAgeException extends AgeException {}
final class AgeTooHighException extends AgeException {}

Domain exceptions hold not just messages but structured data. From the $invalidEmail property, programs can access the invalid email address value—for display, API responses, logging, and more:

catch (InvalidEmailException $e) {
    $logData = [
        'invalid_email' => $e->invalidEmail,    // Programmatically accessible
        'user_ip' => $request->getClientIp(),
        'timestamp' => now()
    ];
    Logger::warning('Invalid email attempt', $logData);
}

Multilingual Messages

The #[Message] attribute lets exceptions speak in the user’s language:

#[Message([
    'en' => 'Name cannot be empty.',
    'ja' => '名前は空にできません。',
    'es' => 'El nombre no puede estar vacío.'
])]
final class EmptyNameException extends \DomainException {}

#[Message([
    'en' => 'Age must be at least {min} years.',
    'ja' => '年齢は最低{min}歳でなければなりません。'
])]
final class AgeTooYoungException extends \DomainException
{
    public function __construct(public int $min = 13) {}
}

Error Collection

The framework does not stop at the first error—it collects all validation errors:

try {
    $user = $becoming(new UserInput('', 'invalid-email', 10));
} catch (SemanticVariableException $e) {
    // Three errors collected simultaneously:
    // - EmptyNameException
    // - InvalidEmailException
    // - AgeTooYoungException

    $messages = $e->getErrors()->getMessages('en');
    // ["Name cannot be empty", "Invalid email format", "Age must be at least 13"]
}

Rather than “fail fast”—understand all problems at once.

Errors as Existence

Error states can be treated as valid metamorphosis results:

#[Be([PaidOrder::class, DeclinedOrder::class])]
final readonly class Payment
{
    public Charged|Declined $being;

    public function __construct(
        #[Input] Money $amount,
        #[Input] CreditCard $card,
        #[Inject] PaymentGateway $gateway
    ) {
        try {
            $this->being = new Charged($gateway->charge($card, $amount));
        } catch (CardDeclinedException $e) {
            $this->being = new Declined($e->reason);
        }
    }
}

Charged and Declined are Reason objects. The type assigned to $being decides whether PaidOrder or DeclinedOrder comes next. Rather than letting the gateway’s exception halt execution, the failure is expressed as a type. Failure, like success, is a legitimate result of transformation.

Semantic variable failures are different. They are collected and raised as SemanticVariableException before the constructor runs, because an object whose names are violated cannot come into existence at all.


For the full picture, see Reference ➡️