Functional PHP helpers
Go to file
2026-10-28 10:05:33 +01:00
src pass by-value only to native filter paths 2026-10-28 10:05:33 +01:00
tests pass by-value only to native filter paths 2026-10-28 10:05:33 +01:00
.gitattributes add license and export-ignore development files 2026-10-27 23:12:18 +01:00
.gitignore upgrade to php 8.2 and phpunit 11 2026-10-27 22:04:21 +01:00
CHANGELOG.md release 1.0.0 2026-10-27 23:13:07 +01:00
composer.json adapt callbacks by target arity 2026-10-27 23:04:09 +01:00
composer.lock upgrade to php 8.2 and phpunit 11 2026-10-27 22:04:21 +01:00
LICENSE add license and export-ignore development files 2026-10-27 23:12:18 +01:00
phpstan-baseline.neon move applyWithArity to internal namespace 2026-10-27 23:00:38 +01:00
phpstan.neon add native fast paths for eager transforms 2026-10-27 23:08:34 +01:00
phpstan.types.neon type accessors for static analysis 2026-10-27 22:44:36 +01:00
phpunit.no-ds.xml add collectors and grouping 2026-10-27 22:19:33 +01:00
phpunit.xml document collections and add type tests 2026-10-27 22:38:37 +01:00
README.md sort natively in sortBy and sort 2026-10-28 09:56:11 +01:00

kadet/functional

Small functional building blocks for PHP 8.2+: predicates, property and method accessors, partial application and currying, and a set of collection functions made for the pipe operator. They work on arrays, generators and php-ds collections.

use Kadet\Functional\Collections as c;
use Kadet\Functional\Collections\Lazy as lazy;
use Kadet\Functional\Predicates as p;
use Kadet\Functional\Transforms as t;

$delayed = $departures
    |> lazy\filter(p\property('delay', p\gt(60)))
    |> c\sortBy(t\property('delay'), descending: true)
    |> c\keyBy(t\property('id'))
    |> c\map(fn (Departure $departure) => "{$departure->line} to {$departure->destination}");

Installation

composer require kadet/functional

The library has no required dependencies. For the collection functions, php-ds is recommended: either the extension (pie install php-ds/ext-ds:^2.0) or the polyfill (composer require php-ds/php-ds). Without it, everything that works on arrays and generators still does; see Without php-ds.

The examples below use this class, and $departures is a list of them:

final class Departure
{
    public function __construct(
        public readonly string $id,
        public readonly string $line,
        public readonly string $destination,
        public readonly int $delay = 0, // in seconds
    ) {
    }
}

$departures = [
    new Departure('d1', '8', 'Stogi'),
    new Departure('d2', '8', 'Stogi', delay: 120),
    new Departure('d3', '12', 'Oliwa', delay: 30),
    new Departure('d4', '12', 'Oliwa', delay: 300),
    new Departure('d5', '6', 'Łostowice'),
];

Predicates

A predicate is a callable object returning a bool. The functions in Kadet\Functional\Predicates build the common ones, and predicates combine with and(), or() and not().

use Kadet\Functional as f;
use Kadet\Functional\Predicates as p;

$late    = p\property('delay', p\gt(60));
$toOliwa = p\property('destination', p\same('Oliwa'));

$late($departures[3]);                    // true
$late->and($toOliwa)($departures[1]);     // false: late, but going to Stogi
$late->or($toOliwa)->not()($departures[0]); // true
f\not($late)($departures[0]);             // true

Comparisons: equals (==), same (===), gt, ge, lt, le. Accessors: property (uses a getX() getter when there is one), field, method. Plus instance($class). f\all(...) and f\any(...) combine many predicates at once, and f\always() / f\never() are constants.

Transforms

Kadet\Functional\Transforms builds accessor functions, handy as selectors:

use Kadet\Functional\Transforms as t;

t\property('line')($departures[0]);  // '8', via getLine() when the object has one
t\field('line')($departures[0]);     // '8', always the field itself
t\method('format', 'H:i')(new DateTimeImmutable('12:30')); // '12:30'

Partial application, currying and composition

use Kadet\Functional as f;
use const Kadet\Functional\_;

// Fix some arguments; `_` leaves a hole to fill later.
$replaceA = f\partial(str_replace(...), 'a', _, 'banana');
$replaceA('o');   // 'bonono'

// A curried function waits until it has all required arguments.
$between = f\curry(fn (int $min, int $max, int $value) => $value >= $min && $value <= $max);
$isHour  = $between(0, 23);
$isHour(12);      // true
$between(0)(59)(60); // false

// Left-to-right composition.
$slug = f\pipe(trim(...), strtolower(...), f\partial(str_replace(...), ' ', '-', _));
$slug('  Main Station '); // 'main-station'

// Anonymous recursion: the function receives itself as the first argument.
$factorial = f\fix(fn ($self, int $n) => $n < 2 ? 1 : $n * $self($n - 1));
$factorial(5);    // 120

// Remember results by arguments.
$slow = f\memoize(fn (string $stop) => strtoupper($stop)); // imagine an expensive lookup
$slow('stogi');   // computed
$slow('stogi');   // remembered

Compiling decorators

partial(), curry(), fix(), pipe() and the predicates return objects. Calling one goes through its __invoke(), which inspects the arguments every time. That is fine for a single call, but in a loop over thousands of elements it adds up. f\compile() flattens such a callable into a plain \Closure once, so the loop pays only for the functions it calls:

use Kadet\Functional as f;
use const Kadet\Functional\_;

$pad = f\compile(f\partial(str_pad(...), _, 3, '0', STR_PAD_LEFT));

$pad('7'); // '007'

Objects implementing Kadet\Functional\Compilable are compiled with their own compile(), also when given as a closure of their __invoke(); any other callable is converted to a closure. Compiling is recursive: a predicate like p\property('delay', p\gt(60)) becomes a closure calling the accessor and the comparison directly. Implement Compilable on your own decorators to get the same benefit. memoize() is not compiled, its cache is the point.

A compiled callable behaves like the object, with two differences. When the call ends in an internal function, arguments it can't take are dropped instead of throwing. And a compiled curry() calls its function with the arguments it gets, without currying further.

You rarely need to call compile() yourself: the collection functions compile every callback once per step, predicates and pipes included.

Collections

The collection functions take their configuration and return a function that takes the data, so a pipeline is a chain of |>:

use Kadet\Functional\Collections as c;            // eager: returns collections
use Kadet\Functional\Collections\Lazy as lazy;    // lazy: returns generators

$lines = $departures
    |> c\filter(fn (Departure $departure) => $departure->delay > 0)
    |> c\map(t\property('line'))
    |> c\unique()
    |> c\values();
// ['8', '12']

The namespace says which flavour you get. c\ is eager and gives a finished collection after every step. lazy\ holds the streaming transforms, which give a generator and do no work until something reads it. Collectors and queries end a chain, turning it into a collection or a single value, so there is nothing to delay and they exist only in c\. They take any iterable, generators included, and are how a lazy chain ends. Sorting needs the whole input before it can give the first element, so it is eager only as well.

Pipes, and calling without them

Every function returns a closure, and there is no "data first" form. One shape for everything means every function composes the same way, and it avoids an ambiguity: a callable array like [$repository, 'find'] is also an iterable, so c\first($something) could not tell data from a predicate.

The pipe operator needs PHP 8.5. On earlier versions, or for a single step, call the closure directly or compose steps with f\pipe():

c\first()($departures);    // the first departure

$firstLate = f\pipe(c\filter(p\property('delay', p\gt(60))), c\first());
$firstLate($departures);   // d2

Where a php-ds method reads better for a one-off ($seq->first()), use it.

Callbacks get the value, then the key

Every callback, predicate and selector is called as $fn($value, $key), for every kind of input, Ds\Map included:

$codes = new Ds\Map(['STG' => 'Stogi', 'OLW' => 'Oliwa']);

$codes |> c\map(fn (string $name, string $code) => "$name ($code)");
// Ds\Map ['STG' => 'Stogi (STG)', 'OLW' => 'Oliwa (OLW)']

Value first, because most callbacks only need the value. It is also the order of array_filter() with ARRAY_FILTER_USE_BOTH. Ds\Map::map() itself passes ($key, $value): when you switch between the two, the library's order is the one that stays the same across arrays, generators and all php-ds types.

Builtins get only the value

Passing the key along is a trap for functions with optional parameters. round() takes a precision as its second argument, so a naive ($value, $key) call would round the first element to 0 places, the second to 1 and the third to 2. To prevent that, two kinds of callables are called with the value only:

  • PHP's internal functions, written as first-class callables: trim(...), round(...), strtoupper(...).
  • Any callable declaring exactly one parameter, like fn ($value) => ... or a one-parameter method.

The library's own wrappers are seen through: what counts is the function a callable ultimately calls. For partial(), curry(), memoize() and f\predicate() that is the wrapped function, for f\not() the negated predicate, and for f\pipe() its first function, the one that gets the arguments. So f\partial(str_pad(...), _, 3, '0'), f\memoize(fn ($stop) => ...) (cached by the value alone) and f\pipe(trim(...), strtolower(...)) all get only the value. f\all() and f\any() decide for each of their predicates: in f\all(is_numeric(...), fn ($value, $key) => ...) the first gets the value, the second the key too.

[1.25, 2.5, 3.14159] |> c\map(round(...));          // [1.0, 3.0, 3.0], not [1.0, 2.5, 3.14]
['  a ', ' b'] |> c\map(trim(...));                  // ['a', 'b']; the key never becomes trim's character list
['n' => ' Main St '] |> c\map(f\pipe(trim(...), strtolower(...))); // ['n' => 'main st']

Any other callable gets the key, even into an optional parameter. A method like format(Departure $departure, string $style = 'short') would receive the key as $style. Wrap it in a one-parameter closure: c\map(fn ($departure) => $formatter->format($departure)).

Builtins are written as first-class callables (trim(...)) throughout. String callables ('trim') work too, but trim(...) fails right where it is written if the function doesn't exist, is understood by IDEs and static analysis, and can't be mistaken for data.

What you get back

Eager transforms return the same kind of container they were given, so a Seq stays a Seq and an array stays an array. The details:

Input Keyed transforms¹ List transforms²
array array, keys preserved list array
Ds\Map Ds\Map, keys preserved Ds\Seq
Ds\Seq Ds\Seq, reindexed Ds\Seq
Ds\Set Ds\Set Ds\Set (but keys() gives a Ds\Seq)
generator, any other iterable Ds\Seq (a list array without php-ds) Ds\Seq (a list array without php-ds)

¹ map, filter, reject, unique, uniqueBy, sort, sortBy, slice, take, skip, merge, each, tap. ² flatMap, values, keys, prepend, append, concat: these produce a list, and a Ds\Map can't hold a list, so it becomes a Ds\Seq.

A few consequences worth knowing:

  • A generator given to an eager transform comes back as a Ds\Seq (or a list array without php-ds). The callbacks still see the generator's keys, but the result is a list. A generator can be read only once, so returning a half-read generator, or a generator at all, from an eager function would be a trap.
  • each()/tap() return their input unchanged for arrays and php-ds collections, which makes them safe to put in the middle of a chain for logging. A generator is consumed by them, so they return its values as a Ds\Seq, not the spent generator.
  • filter() on an array keeps the keys, like array_filter(). Add c\values() when you need a list.
  • map() on a Ds\Set follows Ds\Set::map(), so results that are equal collapse into one.
  • merge() merges arrays and Ds\Map by key (integer keys of arrays are kept, unlike array_merge()); for other inputs it appends.
  • sort()/sortBy() are stable and never modify their input, unlike Ds\Seq::sort().
  • sort() without a comparator and sortBy() sort natively, comparing like <=>. The one difference is enums, which <=> can't order: they are grouped by case and placed after the other values (before them in descending order).

Finding things: first, find and required

first() and last() return null when the collection is empty or nothing matches. A missing value is usually an expected outcome (no departure from this stop right now), so it should not need a try. When the value must be there, say so with required: true, and get an exception that names the function and what went wrong:

$departures |> c\first(p\property('line', p\same('12')));  // d3
$departures |> c\first(p\property('line', p\same('2')));   // null
$departures |> c\first(p\property('line', p\same('2')), required: true);
// EmptyCollectionException: first(): no element matched

[] |> c\last(required: true);
// EmptyCollectionException: last(): collection is empty

required also makes a found null distinguishable: [null] |> c\first(required: true) returns null without throwing, because something was found.

contains($value) and any($predicate) are separate functions on purpose. A string like 'date' is both a value and a valid callable, so one function taking either could not know which you meant. contains() compares strictly (===; a Ds\Key with equals()), any() calls the predicate.

Aliases

Some functions have a second name, so the code can say what it means. Each is a one-line delegation and behaves exactly like its target:

Alias Same as Use it when
find($predicate) first($predicate), with the predicate required you are searching, not taking the head
some($predicate) any($predicate) it reads better in a condition: if ($stops |> c\some(...))
tap($fn) each($fn) the call is for a side effect in the middle of a chain, like logging

Transforms

map, filter (keeps truthy values without a callback), reject, flatMap, values, keys, unique, uniqueBy, sort (comparator or <=>), sortBy (selector, optionally descending: true), slice, take, skip, prepend, append, concat, merge, each, tap.

$delays = $departures
    |> c\reject(fn (Departure $departure) => $departure->delay === 0)
    |> c\sortBy(t\property('delay'))
    |> c\map(fn (Departure $departure) => sprintf('%s: +%d min', $departure->id, $departure->delay / 60))
    |> c\values();
// ['d3: +0 min', 'd2: +2 min', 'd4: +5 min']

Collectors

keyBy, groupBy (a Ds\Map of Ds\Seq, in encounter order), toSeq, toMap, toSet and toArray. All but toArray produce php-ds collections.

$byLine = $departures |> c\groupBy(t\property('line'));
// Ds\Map ['8' => Seq [d1, d2], '12' => Seq [d3, d4], '6' => Seq [d5]]

$byId = $departures |> c\keyBy(t\property('id'));
$byId->get('d4')->destination; // 'Oliwa'

toArray() throws an InvalidArgumentException when a key can't be an array key, like an object key of a Ds\Map, instead of PHP's "Illegal offset type". So does merge() when it merges such keys into an array.

Queries

first, last, find, contains, any, some, every, count, isEmpty, min, max, reduce, sum and join turn a collection into a single value. first, find, any, some, every, contains and isEmpty stop reading as soon as the answer is known, which matters for generators. The others read everything, with two shortcuts: last reads arrays and php-ds collections from the end, and count asks countable containers for their size.

$departures |> c\every(fn (Departure $departure) => $departure->delay < 600); // true
$departures |> c\sum(t\property('delay'));                                    // 450
$departures |> c\max(t\property('delay'));                                    // 300
$departures |> c\map(t\property('line')) |> c\unique() |> c\join(', ');       // '8, 12, 6'
$departures |> c\reduce(fn (int $carry, Departure $departure) => $carry + 1, 0); // 5

min() and max() with a selector return the selected value, not the element. Above, c\max() gives 300, the largest delay, not the departure that has it. To get the element, sort and take the first:

$departures |> c\sortBy(t\property('delay'), descending: true) |> c\first(); // d4

On a tie, min() and max() return the first of the equal values: [1, 1.0] |> c\max() is 1, and of two objects equal under <=> you get the one that came first.

reduce() callbacks take the carry first: fn ($carry, $value, $key). Like the value-only rule above, an internal function or a callable declaring exactly two parameters gets only ($carry, $value), so [5 => 1, 9 => 2] |> c\reduce(max(...), 0) is 2, not the key 9.

Errors

Exceptions name the function and the problem. Two kinds are thrown:

  • Invalid arguments raise PHP's own SPL exceptions: InvalidArgumentException (c\take(-1), a key that can't be an array key in toArray() or merge()) and UnexpectedValueException (a flatMap() callback that returns no iterable).
  • Library conditions raise subclasses of Kadet\Functional\Exception\FunctionalException: EmptyCollectionException (a required query found nothing) and DsUnavailableException (a collector needs php-ds, see below).

Lazy pipelines

lazy\map, filter, reject, flatMap, values, keys, unique, uniqueBy, slice, take, skip, prepend, append, concat, each and tap return a generator. Each element travels through the whole chain before the next one is read, and nothing happens until the chain is read by a collector or a query:

function readDepartures(string $path): Generator
{
    foreach (new SplFileObject($path) as $line) {
        if ($line !== '') {
            yield json_decode($line, true);
        }
    }
}

$firstTram = readDepartures('departures.jsonl')
    |> lazy\filter(fn (array $row) => $row['vehicle'] === 'tram')
    |> lazy\map(fn (array $row) => new Departure($row['id'], $row['line'], $row['destination']))
    |> c\first(); // reads the file only up to the first tram

Choose lazy when:

  • the input is large or streamed (an import, a file, a database cursor): no step builds an intermediate collection, so memory stays flat;
  • you stop early: first, find, any or take on a big input read only as far as needed;
  • the input is infinite, like a generator counting up.

Stay eager when the result is stored, returned from a function or read more than once. A generator can be read only once, and a second foreach over it silently sees nothing (or throws). So don't let a lazy value leave the function that builds it: end the chain with a collector (c\toSeq(), c\keyBy(), ...) or a query. For small collections, eager is also easier to debug, since every step has a value you can look at.

Without php-ds

php-ds is optional. Without it:

  • every transform, lazy or eager, works on arrays and generators; eager transforms given a generator return a list array instead of a Ds\Seq;
  • toArray() and all queries work;
  • keyBy, groupBy, toSeq, toMap and toSet throw Kadet\Functional\Exception\DsUnavailableException ("keyBy() needs php-ds: install ext-ds or require php-ds/php-ds"). They throw when the collector is built, not when data arrives, so $fn = c\keyBy(...) fails at once, at the line that needs php-ds, even if no data ever reaches it.

With php-ds you get Ds\Map keys of any type (objects included), collections that are objects (passed by handle, not copied), and results whose type says whether they are a list (Seq), a mapping (Map) or a set (Set).

Static analysis

Every function in Collections has @template generics, so PHPStan (level 8 in this repository) follows element types through a chain, |> included:

/** @param Ds\Seq<Departure> $departures */
function delayedLines(Ds\Seq $departures): ?string
{
    return $departures
        |> c\filter(fn (Departure $departure): bool => $departure->delay > 0) // Ds\Seq<Departure>
        |> c\map(fn (Departure $departure): string => $departure->line)      // Ds\Seq<string>
        |> c\first();                                                          // string|null
}

Eager transforms with a callback keep the container in the type as well (a Ds\Map in, a Ds\Map out; a generator in, Ds\Seq|list out). A callable PHPStan knows nothing about never causes an error, it only makes the result mixed. The limits of what can be inferred:

  • Element types come from the callback's parameter and return types. fn ($departure) => ... without types gives mixed.
  • Accessors and predicates are not generic. t\property(), t\field(), t\method() and t\getter() are typed \Closure(object): mixed, and the p\ builders return Predicate. Chains using them analyse cleanly, but the elements become object (c\sortBy(t\property('delay'))) or mixed (c\map(t\property('line')), c\max(t\property('delay'))). Where the type matters, write the closure: c\map(fn (Departure $departure): string => $departure->line).
  • sum() doesn't check that the values are numbers when called without a selector.
  • Keys are inferred only when the callback declares a $key parameter. Otherwise the keys of the result are mixed (int|string for arrays).
  • Transforms without a callback (values, keys, unique, sort(), slice, take, skip, prepend, append, concat, merge) keep the element types but are typed as returning iterable: the container type is lost. Follow them with a collector such as c\toSeq() if you need it.
  • With the php-ds polyfill, PHPStan does not treat Ds\Seq as generic: Ds\Seq<T> shows as Ds\Seq&iterable<T>, and the values of a Ds\Map read as mixed, so queries over a map (c\first(), c\last()) give mixed.

Contributing

composer test     # both PHPUnit suites
composer phpstan  # PHPStan level 8: src/ as PHP 8.2 (phpstan.neon), tests/types/ as PHP 8.5 (phpstan.types.neon)
  • composer test runs the main suite and the no-php-ds suite (phpunit.no-ds.xml), which hides the polyfill to check the paths without php-ds. The no-ds suite is skipped when ext-ds is loaded, because an extension can't be hidden: it prints a notice and passes. Run it on a PHP without ext-ds to cover those paths.
  • tests/PipeSyntaxTest.php is the only test that uses |>. It is loaded only on PHP 8.5 and newer (see phpunit.xml), so the rest of the suite still runs on PHP 8.2. Keep |> out of other tests.
  • tests/types/ holds PHPStan type assertions (assertType()). They are analysed, never executed, with their own config, because they use |>. Add one when you change a signature.
  • phpstan-baseline.neon lists errors in code that predates PHPStan in this project. New code must not add to it.