petrknap/optional
Optional brings Java 8-style Optional to PHP, making null handling explicit and safe with isPresent(), get(), orElse(), and ifPresent(). Create strictly typed optionals for bools, strings, arrays, objects, and custom types using built-in traits with full static analysis support.
composer require petrknap/optional — still a small, dependency-free library (LGPL-3.0) with no service provider, facade, or config to publish. v4.0.0 is currently at rc1: pin it ("petrknap/optional": "4.0.0-rc1" or ^4.0@RC) and check composer.json for a bumped PHP platform requirement before you upgrade an existing app.
Start with the README, then src/Optional.php, then tests/. The public surface is PetrKnap\Optional\Optional. The v3 "typed optional" traits/TypedOptional helper are gone in v4 — the type story is now generic PhpDocs plus a covariant template, so read src/ rather than the old README examples.
Treat of(null) as a programming error. v4 explicitly aligns behaviour with Java SE 8's Optional (the test suite is ported from OptionalTest.java), so of() is for values you have already proven non-null, and ofFalsable() is the boundary wrapper:
use PetrKnap\Optional\Optional;
/** @return Optional<User> */
public function findUser(int $id): Optional
{
return Optional::ofFalsable(User::find($id)); // model|null
}
// Caller
$this->findUser($id)->ifPresent(fn (User $u) => $this->notify($u));
Type-safe chains come from docblocks now, not subclasses:
/** @var Optional<User> $user */
$user = $this->findUser($id);
The library is a port of Java SE 8's Optional — isPresent(), get(), orElse(), orElseGet(), orElseThrow(), filter(), map(), flatMap() — so Java familiarity is still the fastest path, and now the semantics match Java rather than approximating it.
Return-type discipline in repositories and services. Still the highest-leverage use: return Optional instead of User|null and let the type system force the caller to acknowledge absence. Pair with Eloquent and a generic docblock:
/** @return Optional<User> */
public function findByEmail(string $email): Optional
{
return Optional::ofFalsable(User::where('email', $email)->first());
}
Chain instead of nested ternaries. orElse($value) takes a value (evaluated eagerly), orElseGet(fn () => ...) is the lazy supplier — keep them distinct, as v4 tightened the getters. Combine with map() to reshape a present value while letting the empty case fall through:
return $this->findUser($id)
->map(fn (User $u) => UserResource::from($u))
->map(fn (UserResource $r) => response()->json($r))
->orElseGet(fn () => response()->json(['error' => 'not found'], 404));
ifPresent for side effects. v4 reworked ifPresent (and map/filter/flatMap) toward the Java contract: ifPresent($consumer) fires only on a present value, and the two-branch form is the Java-style ifPresentOrElse($consumer, $emptyConsumer). Confirm the exact arity in src/Optional.php before wiring it into controllers — the pre-v4 two-callback form should be treated as changed.
Typed optionals without TypedOptional. The TypedOptional helper and the "create your own typed optional" examples were removed, and the base class now uses self instead of static in its factories — so subclassing Optional no longer gives you a factory that returns your subclass. Use @return Optional<User> / @var Optional<Money> docblocks instead; the template is now covariant, so Optional<User> is accepted where Optional<object> is expected, which covers most of what typed wrappers used to give you in practice.
Boundary usage. Wrap values from sources that legitimately return null/false: Eloquent find/first, cache get, config() for optional keys, strpos, array_search, fopen, tmpfile, failed json_decode. Do not wrap everything — Optional is a caller-side abstraction for "maybe present", not a replacement for ordinary null where the semantics are already obvious.
Config lookups. Optional::ofFalsable(config('services.stripe.key'))->orElseThrow(fn () => new RuntimeException(...)) still fails loudly on missing configuration in a service provider or health check without bespoke guard code.
Migration pattern for v3 code. Before upgrading: grep for toNullable (removed — replace with ->orElse(null)), TypedOptional (removed), and any class ... extends Optional (factories no longer return the subclass). Then re-run the test suite for chains built on map/filter/flatMap/ifPresent, whose behaviour deliberately changed.
of() vs ofFalsable() is still the number-one footgun, and v4 makes it sharper. With Java alignment, of(null) is an error rather than a silent empty. ofFalsable() is the "empty on falsy" variant: it treats false, 0, '0', '', and [] as absent, not just null. Great for tmpfile()/fopen()/strpos, dangerous for a legitimate 0 price, quantity, or false flag. Decide per call site; don't default to ofFalsable() everywhere.map now collapses null to empty — on purpose. map() returning null from the callback yields an empty Optional (Java semantics). If you need the callback to produce an optional, use flatMap(), which expects the callback to return an Optional. filter() returns empty when the predicate fails. These were previously fuzzy; v4 pins them, so audit long chains that relied on the old looseness.equals() is now value-based. v4 aligned equals() with Optional.equals: equal when both are empty, or both present with equal contained values. Use ->equals($other) for comparisons. Reference identity (===, spl_object_id(), SplObjectStorage/WeakMap keys) remains unspecified — the value-based contract is unchanged there.toNullable() is gone. Replace every ->toNullable() with ->orElse(null) at the boundary.Optional past the boundary. No Eloquent casts, no toArray()/json_encode() payloads, no cache serialisation, no route model binding. Unwrap with get() or orElse(null) before handing data to Laravel's response layer.optional() helper. Laravel's optional() is a null-safe accessor, not a container. Mixing them in one file is a code-review smell.get() is the exception path. get() on an empty Optional throws. Prefer orElse/orElseGet/ifPresentOrElse; reserve get() for places where an invariant (or a preceding isPresent()) guarantees presence. Don't pass closures to orElse — that's orElseGet.Optional as a method parameter. Accept the concrete value (or ?Foo), return Optional. Optional parameters create double-optional ambiguity and are awkward to mock.dd($optional) / var_dump shows a wrapper, not the value; log ->orElse(null). When a chain returns empty unexpectedly, it's the ofFalsable truthiness trap, a map callback returning null, or an of(null) that you expected to be empty.TypedOptional removed, bare Optional return types are mixed to PHPStan/Psalm unless you annotate @return Optional<Foo>. The upside is that covariance makes these annotations compose properly (Optional<User> satisfies Optional<object>), so prefer annotating at the source method rather than sprinkling @var casts.tests/ now mirrors Java's OptionalTest.java, so when the docs are thin or an RC changed a signature, read the test for the authoritative behaviour instead of guessing — that's faster than diffing the PRs.Optional, cover present and empty; the empty branch is where all the orElse/ifPresentOrElse logic lives and is the one most likely to be silently wrong — especially after this upgrade.4.0.0-rc1), read the release notes for the final 4.0.0, and avoid floating constraints until the stable tag lands.How can I help you explore Laravel packages today?