fp4php/functional
fp4php/functional brings functional programming to PHP with immutable collections, pipelines, pattern matching, and composable helpers. It simplifies data transformation and reduces boilerplate while fitting naturally into Laravel and existing PHP codebases.
composer require fp4php/functional
There is no service provider and no config file — this is a zero-bootstrap library. Everything is plain PHP classes you import directly.
Start with Fp\Functional\Option\Option, because that's the piece Laravel is missing most. The canonical first use case is replacing a nullable return from a repository or Eloquent lookup:
use Fp\Functional\Option\Option;
final class UserRepository
{
public function findById(int $id): Option
{
return Option::fromNullable(User::find($id));
// Some(User) or None — the null is now part of the type
}
}
// Caller
return $this->repo->findById($id)
->map(fn (User $u) => new UserResource($u))
->map(fn (UserResource $r) => response()->json($r))
->getOrElse(response()->json(['error' => 'Not found'], 404));
Where to look first, in order:
src/Fp/Functional/Option/* — Option, Some, None. Read the map/flatMap/filter/match signatures; they're the whole surface area.src/Fp/Functional/Either/* — Left/Right, for error handling without exceptions.src/Fp/Collection/Collection — the immutable, lazy collection. Only reach for this after you've decided Laravel's Collection isn't enough.The second first use case is Either in an action class that has two meaningful failure modes (validation vs. not-found vs. conflict) where you don't want to throw:
use Fp\Functional\Either\Either;
$result = $this->action->execute($dto); // Either
return $result->match(
fn ($ok) => response()->json($ok, 200),
fn ($err) => response()->json(['message' => $err->message], $err->status),
);
Boundary discipline. Monads leak badly if you half-adopt them. Pick a convention: domain/service/action layers return Option/Either, and controllers unwrap them with match, getOrElse, or getOrThrow at the edge. Never let an Option reach a Blade view or a JSON response payload.
Repository → Option. Wrap every first()/find() that can miss. Chain ->map() for transformations and ->flatMap() when the next step can also return Option (the classic flatMap-vs-map mistake is nesting Option<Option<T>>).
Either for domain errors. Define small error value objects (NotFound, Conflict, InvalidTransition) and return Either::left(new NotFound()). This keeps exception handling for genuinely exceptional things and makes the error path explicit in the signature. Since fp4php won't catch for you, wrap third-party throws yourself:
$result = (function () use ($input) {
try {
return Either::right($this->riskyService->call($input));
} catch (DomainException $e) {
return Either::left(new Failed($e->getMessage()));
}
})();
Validated for accumulating errors. If you need all validation failures at once (not just the first), Validated::valid() / Validated::invalid() and combining instances is the reason this library exists over Either. In Laravel you'd typically still use the Validator facade for HTTP forms and reserve Validated for domain-level checks (e.g., multi-line import rows) where Laravel's validator doesn't fit.
Collection pipelines for pure transformations. Collection::of($iterable)->map(...)->filter(...)->groupBy(...) is nice for report/aggregation code where laziness and immutability matter. Convert back out at the end with whatever to-array/list method the version exposes, and remember Laravel Collection itself is iterable, so it can be fed in directly.
Interop, not replacement. Option::fromNullable(), Collection::of($laravelCollection), and extracting with ->getOrElse() are the three functions that make the library coexist with Eloquent and collect(). Use them as adapters at the seams.
Static analysis. The main payoff over hand-rolled null checks is generics annotations. Run PHPStan or Psalm at a decent level — the @template annotations on Option/Either/Collection are what let the analyzer actually track types through map/flatMap. Without a static analyzer you lose half the value.
Fp\Collection\Collection vs Illuminate\Support\Collection is the #1 source of use statement confusion. Alias one: use Fp\Collection\Collection as FpCollection;.collect(). The API overlaps but is not identical, and it's lazy and immutable. ->map() returns a new object; there's no ->push(). If you have existing Collection code, don't do a blind find-and-replace.Option::some(null) is legal and you don't want it. Use Option::fromNullable() when the value might be null; some() is for "I already know this is non-null." Mixing them up produces Some(null) that sails straight through ->map().=== compare monads. Some(1) !== Some(1). Compare via ->isSome()/->isNone()/->isLeft()/->isRight(), or unwrap first and compare the raw values.dd($option) dumps nested wrapper objects. Use ->match(fn ($v) => dd(['some', $v]), fn () => dd('none')) or ->getOrElse('NONE') to see what you actually have.map/flatMap allocates. For tight loops over large arrays, plain array_map or a single Laravel Collection pass will be measurably faster. Reserve fp4php for orchestration-level code, not inner loops.Either and Option model values, not throwables. Any throw inside a map callback escapes the pipeline entirely.Semigroup/Monoid interfaces and the Option/Either interfaces themselves. Implement Semigroup when you want repeated combine-style aggregation of a domain type; implement your own thin monad only if you genuinely need one — otherwise compose Option and Either and stop there.Option through models, Blade, and jobs is the pattern that gets reverted.How can I help you explore Laravel packages today?