Option
Model optional values without null checks scattered everywhere. Some/None with map, flatMap, filter and safe unwrapping.
Read Option
Option, Result, Schema and DTO for PHP 7.4+. Adopt one boundary at a time, no rewrite.
composer require gabrielalmir/maybe$result = Schema::shape([
'email' => Schema::string()->trimmed(),
])->safeParse($payload)
->andThen(fn (array $data) => saveCustomer($data));
$result->match(
fn (Customer $customer) => "Ok: saved",
fn (mixed $error) => "Err: fix input"
);An order-confirmation email fails to send. Either checkout crashes over a non-critical side effect, or the failure is swallowed and nobody finds out the customer was never notified.
// Silently invisible:
@mail($to, $subject, $body);
// "Handled", but the outcome is thrown away:
try {
$mailer->send($to, $subject, $body);
} catch (\Exception $e) {
error_log($e->getMessage());
}The caller has no way to know whether the customer was notified, and no way to tell a malformed address from a timed-out relay.
$emailResult = $emailSchema->safeParse($message)
->andThen(static fn (array $valid): Result
=> sendWithFallback($valid));
$emailResult->match(
static fn (string $ref): string => "sent ({$ref})",
static fn (array $error): string => $error['retryable']
? "queued for retry"
: "rejected: fix the input"
);The error payload keeps `retryable` explicit. A malformed address and a flaky SMTP relay are different problems: one needs a data fix, the other a retry queue. The type stops them being handled identically by accident.
The blocks are designed to compose with each other, in a single dependency that installs on PHP 7.4.
SchemaResult
safeParse() never throws. It returns a Result carrying a ValidationErrorBag.
DTOSchema
One schema, one immutable typed object, built from raw input.
OptionResult
okOr() turns an absent value into a named error at the boundary.
The niche
Not a rewrite, not a framework. One small dependency that installs on the PHP you already have.
No enums, no promotion, no readonly. Tested in CI against 7.4, 8.2, 8.3, 8.4 and 8.5.
Async uses proc_open, not pcntl. A dedicated CI job proves it on Windows.
Global helpers and a loadable library. Adopt one controller at a time.
Process-based concurrency, deliberately not an event loop. Need one? Use amphp or reactphp.
In production
A confirmation email fails. Either the whole checkout crashes over a non-critical side effect, or the failure is silently swallowed.
SAP fails for structured reasons: duplicate document, missing cost center, expired session, timeout. Legacy code collapses them all into the same non-answer.
Validation scattered across a controller lets a contract get half-saved in an invalid state, with errors too unstructured for a review screen to point at the offending field.
A published llms.txt with exact method names and signatures, including the getters that deliberately do not exist, so an assistant stops inventing them.
Paired with AGENTS.md in the repository for anyone contributing with an assistant.
Honesty first. Reach for something else when:
No framework coupling, no rewrite. Validate input, model a fallible service, and decide what happens at the edge.
composer require gabrielalmir/maybeNew here? Read Why Maybe? for the trade-offs, follow the Tutorial to build a validated flow end to end, or keep the API Reference open while you work.