Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Option Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. The docs site (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.
  2. Psl\Option\Option — the interface. Everything else (Some, None) is an implementation detail; type-hint the interface, never the concrete classes.
  3. The two constructors you will use 95% of the time: Psl\Option\some($value), Psl\Option\none(), and the boundary adapter Psl\Option\from_nullable($value).
  4. The method surface: 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.

Implementation Patterns

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.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel