haspadar/primus

Primitive wrappers for PHP: strong typing for strings, ints, arrays, and more

Maintainers

Package info

github.com/haspadar/primus

pkg:composer/haspadar/primus

Statistics

Installs: 443

Dependents: 0

Suggesters: 0

Stars: 4

Open Issues: 0

v0.11.0 2026-05-13 10:06 UTC

This package is auto-updated.

Last update: 2026-05-17 00:28:05 UTC


README

Primus logo

CI Coverage Mutation testing badge PHPStan Level Psalm Level

Object‑Oriented PHP Primitives

Primus is a library of object‑oriented PHP primitives. It provides common operations as small composable objects instead of functions.

Procedural PHP buries the steps inside out — you read from the innermost call:

$result = array_values(array_filter([3, 1, 4, 1, 5], fn ($x) => $x > 2));
sort($result);

Primus reads top to bottom — each step is a named object:

(new Sorted(
    new Filtered(
        new ListOf(3, 1, 4, 1, 5),
        new PredicateOf(fn (int $x) => $x > 2),
    ),
))->value();

The pipeline is a value itself: store it, pass it around, decorate it further. Reading the result is always explicit — call value().

Installation

composer require haspadar/primus

Why?

  • The pipeline is a value.
    Build it, pass it, store it, decorate it further. Nothing runs until value().

    You can return a pipeline from a function, cache it, or wrap it in retry/logging — things you can't do with a procedural chain.

    $headline = new Lowered(new Trimmed(new TextOf($raw)));
    $cached   = new Sticky(new ScalarOf(fn () => $headline->value()));
    // No work done yet.
  • Constructors only remember.
    No I/O, no branches, no work in __construct — just dependency capture.

    Pass any Bytes source into a transformer — in production it can be a real BytesOf(random_bytes(16)), in tests a fixed BytesOf("\x00\x01…") — no framework, no mocking library, just a different constructor argument.

    $bytes = new BytesOf(random_bytes(16));
    $hex   = new HexEncoded($bytes);
  • Every operation is a class.
    Named types replace anonymous array/string/callable.

    You can extend Mapped by wrapping it, not by passing more flags. A Logged(new Mapped(...)) decorator works the same way.

    $doubled = new Mapped(
        new ListOf(1, 2, 3),
        new FuncOf(fn (int $x): int => $x * 2),
    );
  • No null, no mutation, no statics.
    Missing input fails fast; state is readonly; behaviour belongs to instances.

    You can pass a Number deep into your code without ?Number types or null-guards at every boundary.

    $n = new IntegerOf(42);  // explicit type — never returns null
    $n->asInt();             // 42

Text

To trim and lowercase:

$text = (new Lowered(new Trimmed(new TextOf('  Hello  '))))->value();
// "hello"

To take a substring:

$text = (new Sub(new TextOf('Hello, world!'), 0, 5))->value();
// "Hello"

Lists

To filter and sort:

$big = (new Sorted(
    new Filtered(
        new ListOf(3, 1, 4, 1, 5, 9, 2, 6),
        new PredicateOf(static fn (int $x): bool => $x > 2),
    ),
))->value();
// [3, 4, 5, 6, 9]

To pluck a column from a list of rows:

$names = (new Plucked(
    new ListOf(
        ['id' => 1, 'name' => 'Alice'],
        ['id' => 2, 'name' => 'Bob'],
    ),
    'name',
))->value();
// ['Alice', 'Bob']

Maps

To merge two maps with last‑wins precedence:

$merged = (new Merged(
    new MapOf(['a' => 1, 'b' => 2]),
    new MapOf(['b' => 99, 'c' => 3]),
))->value();
// ['a' => 1, 'b' => 99, 'c' => 3]

To index a list of rows by one column, with values from another:

$byId = (new PluckedBy(
    new ListOf(
        ['id' => 1, 'name' => 'Alice'],
        ['id' => 2, 'name' => 'Bob'],
    ),
    'id',
    'name',
))->value();
// [1 => 'Alice', 2 => 'Bob']

Scalars

To compose lazy boolean logic:

$result = (new And_(
    new Constant(true),
    new Not(new Constant(false)),
))->value();
// true

To memoize an expensive computation:

$cached = new Sticky(
    new ScalarOf(static fn () => expensive_computation()),
);
$cached->value(); // expensive_computation() runs once
$cached->value(); // cached

To unwrap an exception chain to its underlying cause:

try {
    $repo->save($entity);
} catch (\Throwable $e) {
    $root = (new RootCause($e))->value();
    logger()->error($root->getMessage());
}

Functions

To wrap a callable as a reusable, swappable object:

$double = new FuncOf(static fn (int $x): int => $x * 2);
$double->apply(21);
// 42

To memoize a function by its input:

$cached = new StickyFunc(
    new FuncOf(static fn (int $id): User => $repo->find($id)),
);
$cached->apply(1); // hits the repo
$cached->apply(1); // cached

To fall back to another function on failure:

$safe = new FuncWithFallback(
    new FuncOf(static fn (string $url): string => http_get($url)),
    new FuncOf(static fn (string $url): string => ''),
);

To run a side-effect over every list element:

(new ForEach_(
    new ListOf('a', 'b', 'c'),
    new ProcOf(fn (string $s) => error_log($s)),
))->exec();

Integers

To wrap a native int and read its projections:

$n = new IntegerOf(42);
$n->asInt();           // 42
$n->asFloat();         // 42.0
$n->asText()->value(); // "42"

To aggregate a list of integers:

$total = (new SumOf(
    new IntegerOf(10),
    new IntegerOf(20),
    new IntegerOf(12),
))->asInt();
// 42

Decimals

To wrap an arbitrary-precision decimal value:

$d = new DecimalOf('100000000000000.000001');
$d->asString(); // "100000000000000.000001" — exact, beyond float53
$d->asText()->value(); // "100000000000000.000001"

Float and int sources stay exact at their own precision:

(new DecimalOfFloat(0.3))->asString(); // "0.3"
(new DecimalOfInt(42))->asString();    // "42"

To do bcmath arithmetic at a chosen scale (digits past the decimal point):

$sum = new SumOf(
    new DecimalOf('0.1'),
    new DecimalOf('0.2'),
    1,
);
$sum->asString(); // "0.3"

$ratio = new DivOf(new DecimalOf('1'), new DecimalOf('3'), 4);
$ratio->asString(); // "0.3333"

Time

To wrap an existing timestamp and format it:

$ts = new TimeOf('2026-05-12T12:00:00Z');
(new Iso($ts))->value();
// "2026-05-12T12:00:00+00:00"

Side-effect sources like the current moment stay on the caller — pass new TimeOf(new DateTimeImmutable()) when you need now, and a fixed new TimeOf('2026-05-12T12:00:00Z') in tests.

Bytes

To hash and hex-encode raw bytes:

$digest = (new HexEncoded(new Sha256(new BytesOf('hello'))))->value();
// "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"

To base64-encode bytes for transport:

$encoded = (new Base64Encoded(new BytesOf('hello')))->value();
// "aGVsbG8="

Random byte sources stay on the caller — feed any Bytes you want, in production new BytesOf(random_bytes(16)), in tests a fixed new BytesOf("\x01\x02…").

Design rules

Every primitive in this library is built to the same set of rules. They explain what you can expect from any class you pick and how your own extensions should look.

  • final readonly classes.
    Every instance is a value — safe to share, pass, decorate, without defensive copies. There are no setters and no inheritance points for "convenience" overrides.

  • No work in constructors.
    Building a graph of objects is always free — no I/O, no parsing, no branching. Failures surface in the computation method (value() / asInt() / exec() …), at the call site that asked for the result.

  • One class, one behaviour.
    When you need two behaviours, compose two classes. Memoization is Sticky. Fallback on failure is FuncWithFallback. Iteration with side-effect is ForEach_. No class carries a flag that toggles its behaviour.

  • Composition over inheritance.
    Every class is final. You change behaviour by wrapping an object, not by subclassing it.

  • No null ever.
    No method returns it, no method accepts it. There is no ?Text, no ?Number. Missing data fails fast at the boundary with a real exception.

  • No static, no isset, no empty.
    Behaviour belongs to instances, never to classes. Signatures must be honest — no hidden "I might be absent" checks.

  • No getters and setters.
    A class exposes behaviour, not data. name() returns a value because asking is a behaviour; there is no setName() because changing state means constructing a new object.

  • Computation is lazy.
    Nothing runs until you call the computation method. A pipeline built with ten decorators costs no CPU until you ask for value().

Enforced by haspadar/sheriff — a curated bundle of PHPStan level 9, Psalm with custom EO rules, PHP‑CS‑Fixer, PHPMD, PHPMetrics, Infection, and repository lints.

Inspired by Elegant Objects (Yegor Bugayenko) and cactoos.

Requirements

PHP 8.3+.

Code style tooling notes

Primus exports class names that collide with PHP pseudo-types in phpdoc: Primus\Integer\Integer, Primus\Scalar\Scalar. Default rules in php-cs-fixer and slevomat/coding-standard compare type names case insensitively, so @param Scalar<T> $x is flagged as if it were the pseudo-type scalar. If your project enforces these tools, exclude the names explicitly.

php-cs-fixer — pass the names to the exclude option of phpdoc_types:

'phpdoc_types' => ['exclude' => ['scalar', 'integer']],

phpcs (slevomat) — disable SlevomatCodingStandard.TypeHints.LongTypeHints for the affected files:

<rule ref="SlevomatCodingStandard.TypeHints.LongTypeHints">
    <exclude-pattern>*/src/Integer/*</exclude-pattern>
    <exclude-pattern>*/src/Scalar/*</exclude-pattern>
</rule>

Sheriff — add to .sheriff.yaml:

override:
    php_cs_fixer.extend: "        'phpdoc_types' => ['groups' => ['meta', 'simple', 'alias'], 'exclude' => ['scalar', 'integer']],"

Working with AI agents

Using an AI coding assistant (Claude Code, Codex, Cursor, Aider, …) with this library? See AGENTS.md for the namespace map, composition contract, antipatterns, and a step-by-step guide for adding new primitives.

License

MIT