awd-studio/vo-optional-php

Type-safe Optional value object for PHP 8.4+. A robust implementation of the Optional pattern inspired by Java, providing elegant null-safety and functional programming capabilities.

Installs: 0

Dependents: 0

Suggesters: 0

Security: 0

Stars: 0

Watchers: 0

Forks: 0

Open Issues: 0

pkg:composer/awd-studio/vo-optional-php

v1.0.0 2026-02-19 09:54 UTC

This package is auto-updated.

Last update: 2026-02-19 10:01:24 UTC


README

PHP Version License Tests

A robust, type-safe implementation of the Optional pattern for PHP 8.4+, inspired by Java's Optional class. This library provides a container object which may or may not contain a non-null value, helping you write cleaner, more expressive code with better null safety.

Inspiration

This implementation follows the design patterns from:

Features

  • Type-Safe: Full generic type support with PHPStan and Psalm annotations
  • Immutable: All operations return new instances, ensuring thread-safety
  • PHP 8.4+: Leverages modern PHP features (readonly classes, mixed types)
  • Zero Dependencies: Lightweight with no external runtime dependencies
  • Fully Tested: Comprehensive test coverage with 77 tests
  • Fluent API: Chainable methods for elegant functional programming
  • Well-Documented: Complete PHPDoc annotations and inline documentation

Installation

composer require awd-studio/vo-optional-php

Requirements

  • PHP 8.4 or higher

Quick Start

use Awd\ValueObject\Optional;

// Create an Optional with a value
$optional = Optional::of('Hello, World!');

// Create an Optional that may be null
$optional = Optional::ofNullable($possiblyNullValue);

// Create an empty Optional
$optional = Optional::empty();

Usage Examples

Basic Value Retrieval

use Awd\ValueObject\Optional;

// Get value or throw exception
$value = Optional::of('test')->get(); // 'test'

// Get value with fallback
$value = Optional::empty()->orElse('default'); // 'default'

// Get value with lazy fallback
$value = Optional::empty()->orElseGet(fn() => expensiveOperation());

// Get value or return null
$value = Optional::empty()->orNull(); // null

// Get value or throw custom exception
$value = Optional::empty()->orElseThrow(fn() => new CustomException());

Working with Domain Entities

use Awd\ValueObject\Optional;

class User {
    public function __construct(
        public readonly int $id,
        public readonly string $name,
        public readonly ?string $email = null
    ) {}
}

class UserRepository {
    public function findById(int $id): Optional {
        $user = $this->db->find($id);
        return Optional::ofNullable($user);
    }
}

// Safe user retrieval
$userName = $repository->findById(123)
    ->map(fn(User $user) => $user->name)
    ->orElse('Guest');

// Chain operations on entities
$userEmail = $repository->findById(123)
    ->map(fn(User $user) => $user->email)
    ->filter(fn(?string $email) => null !== $email)
    ->map(fn(string $email) => strtolower($email))
    ->orElse('no-reply@example.com');

Value Object Transformations

use Awd\ValueObject\Optional;

class Email {
    private function __construct(public readonly string $value) {}

    public static function fromString(string $email): Optional {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            return Optional::empty();
        }
        return Optional::of(new self($email));
    }
}

class PhoneNumber {
    private function __construct(public readonly string $value) {}

    public static function fromString(string $phone): Optional {
        if (!preg_match('/^\+?[1-9]\d{1,14}$/', $phone)) {
            return Optional::empty();
        }
        return Optional::of(new self($phone));
    }
}

// Safe value object creation
$email = Email::fromString($input)
    ->map(fn(Email $e) => $e->value)
    ->orElseThrow(fn() => new InvalidArgumentException('Invalid email'));

// Chaining value object operations
$contact = Optional::ofNullable($user->email)
    ->flatMap(fn(string $e) => Email::fromString($e))
    ->or(fn() => Optional::ofNullable($user->phone)
        ->flatMap(fn(string $p) => PhoneNumber::fromString($p)))
    ->orElseThrow(fn() => new ContactRequiredException());

Repository Pattern Integration

use Awd\ValueObject\Optional;

interface OrderRepository {
    public function findByOrderNumber(string $orderNumber): Optional;
}

class Order {
    public function __construct(
        public readonly string $orderNumber,
        public readonly string $status,
        public readonly array $items
    ) {}

    public function isPaid(): bool {
        return $this->status === 'paid';
    }
}

// Safe order processing
$orderRepository->findByOrderNumber('ORD-123')
    ->filter(fn(Order $order) => $order->isPaid())
    ->ifPresentOrElse(
        fn(Order $order) => $this->shipOrder($order),
        fn() => logger()->warning('Order not found or not paid')
    );

Aggregate Root Operations

use Awd\ValueObject\Optional;

class Customer {
    public function __construct(
        public readonly int $id,
        public readonly string $name,
        private ?Address $billingAddress = null
    ) {}

    public function getBillingAddress(): Optional {
        return Optional::ofNullable($this->billingAddress);
    }
}

class Address {
    public function __construct(
        public readonly string $street,
        public readonly string $city,
        public readonly string $country
    ) {}
}

// Navigate aggregate roots safely
$country = $customerRepository->findById($customerId)
    ->flatMap(fn(Customer $c) => $c->getBillingAddress())
    ->map(fn(Address $a) => $a->country)
    ->orElse('Unknown');

Service Layer with Optional

use Awd\ValueObject\Optional;

class NotificationService {
    public function notifyUser(int $userId, string $message): void {
        $this->userRepository->findById($userId)
            ->flatMap(fn(User $user) => $user->getPreferredContact())
            ->ifPresentOrElse(
                fn(Contact $contact) => $this->send($contact, $message),
                fn() => $this->logger->info("No contact method for user {$userId}")
            );
    }
}

class PaymentProcessor {
    public function refund(string $transactionId): Optional {
        return $this->transactionRepository->findById($transactionId)
            ->filter(fn(Transaction $t) => $t->canBeRefunded())
            ->map(fn(Transaction $t) => $this->processRefund($t))
            ->flatMap(fn(RefundResult $r) =>
                $r->isSuccessful()
                    ? Optional::of($r)
                    : Optional::empty()
            );
    }
}

Transformations

use Awd\ValueObject\Optional;

// Transform value with map()
$result = Optional::of('john')
    ->map(fn($name) => strtoupper($name))
    ->get(); // 'JOHN'

// Chain multiple transformations
$result = Optional::of(5)
    ->map(fn($n) => $n * 2)
    ->map(fn($n) => $n + 10)
    ->get(); // 20

// FlatMap for nested Optionals
$result = Optional::of('user@example.com')
    ->flatMap(fn($email) => validateEmail($email))
    ->flatMap(fn($email) => findUserByEmail($email))
    ->orElse(null);

Conditional Operations

use Awd\ValueObject\Optional;

// Filter with predicate
$result = Optional::of(25)
    ->filter(fn($age) => $age >= 18)
    ->orElse('underage'); // 25

// Execute action if present
Optional::of($user)
    ->ifPresent(fn($u) => sendEmail($u));

// Execute action or alternative
Optional::ofNullable($config)
    ->ifPresentOrElse(
        fn($cfg) => applyConfig($cfg),
        fn() => useDefaultConfig()
    );

Checking Presence

use Awd\ValueObject\Optional;

$optional = Optional::of('value');

if ($optional->isPresent()) {
    // Value exists
}

if ($optional->isEmpty()) {
    // No value
}

Alternative Optional

use Awd\ValueObject\Optional;

// Return alternative Optional if empty
$result = Optional::empty()
    ->or(fn() => Optional::of('alternative'))
    ->get(); // 'alternative'

// Chain alternatives
$result = Optional::empty()
    ->or(fn() => tryPrimarySource())
    ->or(fn() => trySecondarySource())
    ->orElse('fallback');

Equality and String Representation

use Awd\ValueObject\Optional;

$opt1 = Optional::of('test');
$opt2 = Optional::of('test');

$opt1->equals($opt2); // true

echo Optional::of('value')->toString(); // "Optional[value]"
echo Optional::empty()->toString(); // "Optional.empty"
echo Optional::of(42); // "Optional[42]" (uses __toString)

API Reference

Creation Methods

  • Optional::empty() - Creates an empty Optional
  • Optional::of($value) - Creates Optional with non-null value (throws if null)
  • Optional::ofNullable($value) - Creates Optional that may contain null

Value Retrieval

  • get() - Returns value or throws NoSuchElementException
  • orElse($other) - Returns value or provided default
  • orElseGet(Closure $supplier) - Returns value or result of supplier
  • orElseThrow(Closure $exceptionSupplier) - Returns value or throws exception
  • orNull() - Returns value or null
  • or(Closure $supplier) - Returns this Optional or alternative Optional

Transformations

  • map(Closure $mapper) - Transforms value if present
  • flatMap(Closure $mapper) - Transforms value and flattens nested Optional

Conditional Operations

  • filter(Closure $predicate) - Filters value by predicate
  • ifPresent(Closure $action) - Executes action if value present
  • ifPresentOrElse(Closure $action, Closure $emptyAction) - Executes action or alternative

State Checking

  • isPresent() - Returns true if value exists
  • isEmpty() - Returns true if no value

Utilities

  • equals(Optional $other) - Checks equality with another Optional
  • toString() - Returns string representation
  • __toString() - Magic method for string casting

Exception Handling

The library provides two custom exceptions:

NullPointerException

Thrown when attempting to create an Optional with null using of():

try {
    Optional::of(null); // Throws NullPointerException
} catch (NullPointerException $e) {
    echo $e->getMessage(); // "Value must not be null"
}

NoSuchElementException

Thrown when accessing value from empty Optional:

try {
    Optional::empty()->get(); // Throws NoSuchElementException
} catch (NoSuchElementException $e) {
    echo $e->getMessage(); // "No value present"
}

Both exceptions support custom messages:

throw new NullPointerException('Custom error message');

Type Safety

This library provides comprehensive type safety through PHPStan and Psalm annotations:

/** @var Optional<User> */
$userOptional = Optional::ofNullable($user);

/** @var Optional<string> */
$nameOptional = $userOptional->map(fn(User $u) => $u->getName());

Best Practices

  1. Use ofNullable() for potentially null values

    // Good
    Optional::ofNullable($possiblyNull)
    
    // Bad - throws if null
    Optional::of($possiblyNull)
  2. Prefer orElseGet() over orElse() for expensive operations

    // Good - lazy evaluation
    $value = $optional->orElseGet(fn() => expensiveCall());
    
    // Bad - always evaluated
    $value = $optional->orElse(expensiveCall());
  3. Chain operations for readability

    $result = Optional::ofNullable($input)
        ->filter(fn($v) => strlen($v) > 0)
        ->map(fn($v) => trim($v))
        ->map(fn($v) => strtoupper($v))
        ->orElse('DEFAULT');
  4. Use flatMap() to avoid nested Optionals

    // Good
    $email = Optional::of($user)
        ->flatMap(fn($u) => Optional::ofNullable($u->getEmail()))
        ->orElse(null);
    
    // Bad - returns Optional<Optional<string>>
    $email = Optional::of($user)
        ->map(fn($u) => Optional::ofNullable($u->getEmail()));

Development

Setup with Docker (Recommended)

# Initialize project
make init

# Start containers
make start

# Stop containers
make stop

# Rebuild containers
make rebuild

# Open PHP container shell
make php

Setup with Composer

# Install dependencies
composer install

# Setup development tools
composer dev-tools-setup

Testing

# Run all tests with quality checks (Docker)
make test

# Run all tests with quality checks (Composer)
composer test

# Run only PHPUnit tests
composer phpunit

# Run static analysis
composer phpstan

# Fix code style
composer code-fix

Available Make Commands

# Show all available commands
make help

# Project lifecycle
make init              # Initialize the project
make rebuild           # Rebuild Docker containers
make start             # Start containers
make stop              # Stop containers
make down              # Remove containers

# Testing and quality
make test              # Run all tests
make code-fix          # Fix code style issues

# Composer operations
make composer-install  # Install dependencies
make composer-update   # Update dependencies

# PHP container access
make php               # Open PHP container shell

Quality Tools

  • PHPUnit 13+ - Unit testing with 77 tests
  • PHPStan (max level) - Static analysis
  • PHP CS Fixer - Code style enforcement
  • Rector - Code modernization
  • Psalm - Additional type checking

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add some amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

License

This project is licensed under the MIT License - see the LICENSE file for details.

Credits

Changelog

See CHANGELOG.md for version history.

Support

For bugs, questions, and discussions please use the GitHub Issues.