php-standard-library/option
Option type for PHP with explicit Some/None presence semantics, replacing nullable values so absence is handled intentionally instead of silently. Part of the PHP Standard Library.
Install
composer require php-standard-library/option
Note the packaging: this is a split package from the wider PHP Standard Library monorepo. Many projects will end up pulling azjezz/psl (the historical name) or the metapackage and get Psl\Option transitively — check composer show before assuming the standalone package is what's loaded, and never install both.
Look here first
php-standard-library.dev) — the repository README is a signpost, not documentation. The release notes for 6.1.x–6.3.x all redirect to the main repo release page, so the changelog you actually need lives at php-standard-library/php-standard-library/releases.Psl\Option\Option — the interface. Everything else (Some, None) is an implementation detail; type-hint the interface, never the concrete classes.Psl\Option\some($value), Psl\Option\none(), and the boundary adapter Psl\Option\from_nullable($value).isSome(), isNone(), unwrap(), unwrapOr(), unwrapOrElse(), map(), flatMap(), filter(), match()/andThen(). Skim these once, in full, before writing any code — the whole value of the package is that you stop writing if ($x !== null).First use case
Take a single repository lookup and change the contract, not the caller stack:
use Psl\Option\Option;
use function Psl\Option\from_nullable;
use function Psl\Option\none;
final class UserRepository
{
public function findById(int $id): Option // Option<User>
{
return from_nullable(User::find($id));
}
}
Then at the one call site that needs the value:
$name = $repo->findById($id)
->map(fn (User $u) => $u->name)
->unwrapOr('Guest');
That is the entire pitch in five lines: no ?->, no ??, and the signature now says absence is expected rather than accidental.
Put Option at the boundaries, not everywhere
The most common failure mode in an assessment is treating Option as a global replacement for ?Type. In practice it works best as a domain-core type with adapters at the edges:
HTTP / Eloquent / config ──from_nullable()──▶ Option ──unwrapOr/map──▶ HTTP response
Controllers, Blade views, JSON responses, and Eloquent attribute hydration all speak null. Convert on the way in, convert on the way out. This keeps the migration incremental and stops Option leaking into places Laravel can't handle (see Gotchas).
Replace nested null-checks with a chain
// before
$city = null;
if ($user !== null && $user->address !== null) {
$city = $user->address->city;
}
// after
$city = $userRepo->findById($id) // Option<User>
->flatMap(fn (User $u) => $u->address()) // Option<Address>
->map(fn (Address $a) => $a->city)
->unwrapOr('Unknown');
flatMap is the one to internalise: use it whenever your closure returns another Option, or you'll get Option<Option<T>> and the unwrapOr will hand you an object instead of a string.
Use filter to collapse validation into the chain
$discount = from_nullable($request->input('discount_code'))
->filter(fn (string $code) => strlen($code) >= 4)
->flatMap(fn (string $code) => $couponRepo->findActive($code));
This is where Option genuinely beats null: absent, malformed, and expired all land in None, and the happy path stays a single expression.
Distinguish "not provided" from "provided but invalid"
This is the API-layer win that nullable rules cannot express cleanly:
$email = array_key_exists('email', $payload)
? from_nullable($payload['email']) // Some(...) or None (null given explicitly)
: none(); // key absent entirely
Map None → 400/422 at the response boundary, Some → proceed. Document which of the two means what in your API contract.
Defaults for config and env
$host = from_nullable(env('DB_HOST'))->unwrapOr('127.0.0.1');
Keep this to unwrapOr/unwrapOrElse. Don't reach for unwrap() in config paths — it throws, and a missing env var should not be a fatal.
Collection interop
Option is not a Laravel Collection, so bridge explicitly at the seam:
$users = collect($ids)
->map(fn (int $id) => $repo->findById($id)) // Collection<Option<User>>
->filter(fn (Option $o) => $o->isSome())
->map(fn (Option $o) => $o->unwrap())
->values();
If you find yourself writing this filter/unwrap pair repeatedly, extract a tiny helper (collect_options(array $options): Collection) once and reuse it.
Testing
Assert on the variant, not on the wrapped value's presence:
self::assertTrue($repo->findById(1)->isSome());
self::assertSame('Ada', $repo->findById(1)->unwrap()->name);
self::assertTrue($repo->findById(9999)->isNone());
Add a match-based assertion helper if the suite gets noisy. Keep unwrap() in tests only — that's the one place a throw-on-None is the correct behaviour.
Static analysis
Annotate generics in docblocks (@return Option<User>), configure PHPStan/Psalm to read them, and then use the analysis to drive migration: it will flag every place still treating the result as nullable. Do not turn on Option-enforcement rules repo-wide on day one; baseline first, then ratchet.
Some(null) is the classic footgun
some(null) is a valid Some containing null, which defeats the entire point and will break map closures that assume a real value. Always go through from_nullable() when the source might be nullable. If your team is large, consider a PHPStan rule banning some() outside factories.
Do not type-hint Option in Laravel-injected positions
The container, middleware signatures, FormRequest rules(), and event listeners will not resolve or coerce Option. Keep it to return types and parameters of your services and value objects. Same for Eloquent casts: an Option cast on a model attribute fights with hydration, toArray(), and $model->save().
Serialization stops at the boundary
json_encode(Option::some($user)) produces the wrapper's public shape, not the user. Always convert before responding:
return $option->map(fn (User $u) => $u->toArray())->unwrapOr(null);
Equality is not what you expect
Two separate Some(5) instances are not == in the way you might want for assertions or cache keys. Compare unwrapped values ($a->unwrapOr($sentinel) === $b->unwrapOr($sentinel)), not the wrappers.
unwrap() is an assertion, not control flow
It throws. Every unwrap() in production code is a null dereference waiting to happen with worse diagnostics. Grep for it during review; legitimate uses are almost entirely in tests and in main()-level fail-fast paths.
Blade has no idea what this is
You'll be writing $option->isSome() ? $option->unwrap()->name : 'N/A' in templates, which is worse than the ?? you removed. Either resolve to a value in the controller/view-model, or add one Blade directive (@some($option)) and use it consistently. The TPM assessment's public function boot(): Option example is not something you should copy — service-provider boot() return values are ignored.
Debugging is slightly worse
dump() / dd() on an Option shows wrapper internals rather than the value, and stack traces for a deep map/flatMap chain collapse into the library rather than your domain. When a chain misbehaves, break it: $a = step1(); $b = $a->map(...); dump($b->isSome());. Adding a tap/dump-style side effect mid-chain usually pays for itself the first time you debug one of these.
Performance is a non-issue — until it isn't
The overhead is object allocation per Some. Irrelevant in request-scoped code; measurable if you wrap every row of a 500k-row export or a tight queue-worker loop. Keep Option out of raw iteration internals and you'll never notice it.
Version pinning and bus factor
Releases here are cut in lockstep with the monorepo (6.1.x → 6.3.x) and the per-component release notes carry no detail — you must watch the main repository's changelog for behavioural changes. With effectively one maintainer and ~zero dependents, treat the package as stable but unsupported: pin a minor version, and budget an hour to read the Option source (it is small) so you're not blocked if a release stalls. MIT license means forking is a realistic fallback.
Know when to stop
Do not convert a two-branch CRUD controller. Option pays off where a value crosses three or more layers, where absence has distinct business meaning, or where a chain of dependent lookups would otherwise nest ifs. If a reader needs the docs to understand Option::some(1)->unwrap(), you've made the code worse — and remember the whole team has to be fluent in map/flatMap for this to be a net gain. Pilot it on one domain (a lookup-heavy API resource is ideal), measure the diff size and the bug rate, then decide.
Extension point
The interface is the extension point: implement Option (or wrap it) if you need Laravel-aware helpers — Option::toJson(), Option::toCollection(), a rescue() around unwrap() — and keep those in your own package so library upgrades stay cheap.
How can I help you explore Laravel packages today?