Skip to content

Sets and maps

phil/enums

Two structures keyed by the cases of one enum, which PHP arrays cannot be: EnumSet, a set of cases, and EnumMap, a value for every case. Both are immutable and know their enum even when they hold nothing.

EnumSet

A set holds each case at most once, always in declaration order, and offers the operations of a set rather than those of a list: what permissions, the days a shop opens or the channels a person accepts are.

use Phil\Enums\Collection\EnumSet;

$open = EnumSet::of(Day::class, Day::Monday, Day::Tuesday, Day::Friday);
$weekend = EnumSet::of(Day::class, Day::Saturday, Day::Sunday);

$open->contains(Day::Friday);           // true
$open->union($weekend)->names();        // ['Monday', 'Tuesday', 'Friday', 'Saturday', 'Sunday']
$open->complement()->names();           // ['Wednesday', 'Thursday', 'Saturday', 'Sunday']
$open->without(Day::Friday)->count();   // 2
MethodReturns
EnumSet::of($enum, ...$cases)The set of these cases; a case of another enum is refused.
EnumSet::none($enum), EnumSet::all($enum)The empty set, every case.
EnumSet::fromBitmask($enum, $mask)The set a bitmask stands for.
EnumSet::fromValues($enum, $values)The set of the cases these values — names for a pure enum — stand for, as a form or a request sends them; a value no case has is refused.
contains($case), isEmpty(), count()Whether it holds the case, whether it holds none, how many.
with(...$cases), without(...$cases)The set with, or without, these cases.
union($other), intersect($other), diff($other)The cases in either, in both, in this one only.
complement()The cases of the enum this set does not hold.
equals($other), isSubsetOf($other), isSupersetOf($other)Comparisons between sets.
cases()The cases, as a CaseCollection to filter, sort or map (Operations).
names(), values(), toBitmask()The names, the values — the names of a pure enum —, the bitmask.
enum()The enum class.

It is Countable and iterable. An operation between sets of two enums throws an InvalidArgumentException.

Bit flags

When the enum is backed by integers that are powers of two — the flags of C# and Python —, a whole set fits in one integer: the values of its cases, or'ed.

enum Permission: int
{
    case Read = 1;
    case Write = 2;
    case Delete = 4;
}

EnumSet::of(Permission::class, Permission::Read, Permission::Delete)->toBitmask(); // 5
EnumSet::fromBitmask(Permission::class, 5)->names();                               // ['Read', 'Delete']

Mark such an enum #[BitFlags]: the PHPStan extension then reports a case whose value is not a power of two, before the run time does (PHPStan).

toBitmask() and fromBitmask() throw a LogicException for an enum with a case that is not a bit — pure, backed by strings, 0, or an integer that is not a power of two —, and fromBitmask() throws an InvalidArgumentException for a mask with a bit no case stands for. Never renumber a flag once masks are stored: the values are the storage format.

EnumMap

A map holds a value for every case: the colour of each status, the price of each plan. Iterating it yields the cases as keys.

use Phil\Enums\Collection\EnumMap;

$colours = EnumMap::total(Status::class, static fn (Status $status): string => match ($status) {
    Status::DRAFT => 'grey',
    Status::PUBLISHED => 'green',
    Status::ARCHIVED => 'brown',
});

$colours->get(Status::PUBLISHED); // 'green'

foreach ($colours as $status => $colour) {
    // $status is a Status case
}

Total by construction. total() calls the callback for every case, and a match without a default arm makes PHP throw — and PHPStan report — the case it forgets: a case added to the enum fails where the map is made, not as a missing key the day a record reaches it. fromArray() reads a map from an array keyed by values, as configuration writes it, and refuses one that misses a case or has a key that is no case, naming both.

EnumMap::fromArray(Status::class, ['draft' => 'grey', 'published' => 'green', 'archived' => 'brown']);
EnumMap::fromPartial(Status::class, ['published' => 'green'], 'grey');  // draft and archived are grey

fromPartial() keeps the table total while naming the choice: a forgotten case takes the default you wrote, not a missing key.

MethodReturns
EnumMap::total($enum, $callback)The map of what the callback gives each case.
EnumMap::fromArray($enum, $values)The map of an array keyed by the values of the cases, their names for a pure enum.
EnumMap::fromPartial($enum, $values, $default)The same, each case the array leaves out taking the default; a key that is no case is still refused.
get($case)The value of the case.
with($case, $value)The same map with this value for the case.
map($callback)The map of what the callback makes of each value; it receives the value and the case.
keys(), values()The cases as a CaseCollection, the values as a list.
toArray()The values keyed by the values of the cases: what fromArray() reads back.
enum(), count()The enum class, the number of cases.

get() and with() throw an InvalidArgumentException for a case of another enum.

Cost

Enums are small, so the structures keep plain lists rather than indexes. Measured on PHP 8.5 with OPcache, for an enum of five cases: contains() takes about 0.13 µs, union() about 2 µs, fromBitmask() about 2.7 µs. An index by name would save nothing worth its code at these sizes.