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
Requires
- php: ^8.4
Requires (Dev)
- dg/bypass-finals: ^1.9
- ergebnis/composer-normalize: ^2.50
- phpspec/prophecy: ^1.25
- phpspec/prophecy-phpunit: ^2.5
- phpunit/phpunit: ^13.0
- roave/security-advisories: dev-latest
README
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 OptionalOptional::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 throwsNoSuchElementExceptionorElse($other)- Returns value or provided defaultorElseGet(Closure $supplier)- Returns value or result of supplierorElseThrow(Closure $exceptionSupplier)- Returns value or throws exceptionorNull()- Returns value or nullor(Closure $supplier)- Returns this Optional or alternative Optional
Transformations
map(Closure $mapper)- Transforms value if presentflatMap(Closure $mapper)- Transforms value and flattens nested Optional
Conditional Operations
filter(Closure $predicate)- Filters value by predicateifPresent(Closure $action)- Executes action if value presentifPresentOrElse(Closure $action, Closure $emptyAction)- Executes action or alternative
State Checking
isPresent()- Returns true if value existsisEmpty()- Returns true if no value
Utilities
equals(Optional $other)- Checks equality with another OptionaltoString()- 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
-
Use
ofNullable()for potentially null values// Good Optional::ofNullable($possiblyNull) // Bad - throws if null Optional::of($possiblyNull)
-
Prefer
orElseGet()overorElse()for expensive operations// Good - lazy evaluation $value = $optional->orElseGet(fn() => expensiveCall()); // Bad - always evaluated $value = $optional->orElse(expensiveCall());
-
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');
-
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.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
License
This project is licensed under the MIT License - see the LICENSE file for details.
Credits
- Author: Anton Karpov (awd.com.ua@gmail.com)
- Inspired by Java's Optional class (JDK 17)
- Built with modern PHP 8.4 features
Changelog
See CHANGELOG.md for version history.
Support
For bugs, questions, and discussions please use the GitHub Issues.