PHPackages                             nalabdou/algebra-php - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. nalabdou/algebra-php

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

nalabdou/algebra-php
====================

Pure PHP relational algebra engine — JOIN, PIVOT, WINDOW, GROUP BY, 60+ operations. Zero dependencies.

1.1.0(3mo ago)082↓87.5%2MITPHPPHP &gt;=8.2CI passing

Since Mar 15Pushed 2mo agoCompare

[ Source](https://github.com/nalabdou/algebra-php)[ Packagist](https://packagist.org/packages/nalabdou/algebra-php)[ RSS](/packages/nalabdou-algebra-php/feed)WikiDiscussions main Synced 3w ago

READMEChangelog (2)Dependencies (3)Versions (4)Used By (2)

algebra-php
===========

[](#algebra-php)

**Pure PHP relational algebra engine.**

JOIN · PIVOT · WINDOW · GROUP BY · 60+ operations · Zero framework dependency.

[![PHP](https://camo.githubusercontent.com/09e15f01637450dfcaa8ea133c36f65a6b38d88886ecfb9ecca024697844d6f7/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048502d382e322b2d626c75653f6c6f676f3d706870)](https://php.net)[![License](https://camo.githubusercontent.com/5caa455d8debc46fb23abbadb45a733a937f3910a73fc875c2f7820468e1bb54/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4d49542d677265656e)](LICENSE)[![PHPStan](https://camo.githubusercontent.com/2de44fa415e74513b3ab0978012f8b4bb8e37dafe58e2d27f779705b278f0373/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048505374616e2d6c6576656c253230352d627269676874677265656e)](phpstan.neon)

---

Why algebra-php?
----------------

[](#why-algebra-php)

You have two arrays from Doctrine, an API response, or a CSV file. You need to join them, group them, compute running totals, and build a pivot table — all in PHP, all in one readable pipeline.

Without algebra you write nested loops, multiple `array_filter` calls, manual aggregation, and bespoke pivot code spread across multiple methods. With algebra-php you write this:

```
$result = Algebra::from($orders)
    ->where("item['status'] == 'paid'")
    ->innerJoin($users, leftKey: 'userId', rightKey: 'id', as: 'owner')
    ->groupBy('region')
    ->aggregate(['revenue' => 'sum(amount)', 'orders' => 'count(*)'])
    ->orderBy('revenue', 'desc')
    ->toArray();
```

---

Installation
------------

[](#installation)

```
composer require nalabdou/algebra-php
```

Requires PHP 8.2+.

---

Quick start
-----------

[](#quick-start)

```
use Nalabdou\Algebra\Algebra;

// From any input — array, generator, Traversable
$result = Algebra::from($orders)
    ->where("item['status'] == 'paid' and item['amount'] > 100")
    ->innerJoin($users, leftKey: 'userId', rightKey: 'id', as: 'owner')
    ->groupBy('region')
    ->aggregate([
        'revenue' => 'sum(amount)',
        'orders'  => 'count(*)',
        'avg'     => 'avg(amount)',
    ])
    ->orderBy('revenue', 'desc')
    ->toArray();
```

---

All operations
--------------

[](#all-operations)

### Entry point

[](#entry-point)

```
Algebra::from($input)           // array, generator, Traversable → RelationalCollection
Algebra::pipe($input, $fn)      // build + execute in one expression
Algebra::parallel(['a' => $c1, 'b' => $c2])  // concurrent via PHP Fibers
```

---

### Joins

[](#joins)

```
// INNER JOIN — unmatched left rows dropped — O(n+m) hash index
->innerJoin($right, leftKey: 'userId', rightKey: 'id', as: 'owner')

// LEFT JOIN — unmatched left rows kept with null
->leftJoin($right, on: 'userId=id', as: 'owner')

// SEMI JOIN — existence check, no right data attached
->semiJoin($right, leftKey: 'id', rightKey: 'orderId')

// ANTI JOIN — rows with no match on right
->antiJoin($right, leftKey: 'id', rightKey: 'orderId')

// CROSS JOIN — cartesian product (all left × all right)
->crossJoin($right, leftPrefix: 'size_', rightPrefix: 'colour_')

// ZIP — positional merge (index 0 with 0, 1 with 1, …)
->zip($right, leftAs: 'label', rightAs: 'value')
```

---

### Set operations

[](#set-operations)

```
->intersect($right, by: 'productId')   // A ∩ B — rows in both
->except($right, by: 'id')             // A − B — rows only in left
->union($right, by: 'email')           // A ∪ B — merged, deduplicated
->symmetricDiff($right, by: 'id')      // A △ B — rows in one but not both
```

---

### Filter &amp; projection

[](#filter--projection)

```
// WHERE — string expression
->where("item['status'] == 'paid' and item['amount'] > 100")
->where("contains(item['email'], '@company.com')")
->where("length(item['name']) > 3")

// WHERE — closure (zero overhead, full PHP)
->where(fn($r) => $r['status'] === 'paid' && $r['amount'] > 100)

// SELECT — project each row
->select('id')                               // pluck single field
->select(fn($r) => ['id' => $r['id'],
                     'name' => strtoupper($r['name'])])
```

---

### Grouping &amp; aggregation

[](#grouping--aggregation)

```
->groupBy('status')
->groupBy("item['region'] ~ '-' ~ item['year']")
->groupBy(fn($r) => substr($r['createdAt'], 0, 7))   // YYYY-MM

->aggregate([
    'count'         => 'count(*)',
    'total'         => 'sum(amount)',
    'average'       => 'avg(amount)',
    'minimum'       => 'min(amount)',
    'maximum'       => 'max(amount)',
    'median_val'    => 'median(amount)',
    'std_dev'       => 'stddev(amount)',
    'variance_val'  => 'variance(amount)',
    'p90'           => 'percentile(amount, 0.9)',
    'distinct_users'=> 'count_distinct(userId)',
    'product_list'  => 'string_agg(name, ", ")',
    'all_shipped'   => 'bool_and(shipped)',
    'any_digital'   => 'bool_or(isDigital)',
    'first_date'    => 'first(createdAt)',
    'last_date'     => 'last(createdAt)',
])

->tally('status')     // → ['paid'=>42, 'pending'=>12, 'cancelled'=>3]
```

---

### Window functions

[](#window-functions)

```
// Running aggregates
->window('running_sum',   field: 'amount', as: 'cumulative')
->window('running_avg',   field: 'amount', as: 'moving')
->window('running_count', field: 'id',     as: 'rowCount')
->window('running_diff',  field: 'amount', as: 'delta')

// Ranking
->window('row_number',  field: 'id',     as: 'rowNum')
->window('rank',        field: 'amount', as: 'rank')       // gaps on ties
->window('dense_rank',  field: 'amount', as: 'denseRank')  // no gaps

// Offset
->window('lag',  field: 'amount', as: 'prevAmount', offset: 1)
->window('lead', field: 'amount', as: 'nextAmount', offset: 1)

// Statistical
->window('ntile',     field: 'amount', as: 'quartile', buckets: 4)
->window('cume_dist', field: 'amount', as: 'pct')

// Partition — resets window per group
->window('running_sum', field: 'amount', as: 'userTotal', partitionBy: 'userId')

// Shorthand window operations
->movingAverage(field: 'revenue', window: 7, as: 'avg_7d')
->normalize(field: 'price', as: 'priceScore')           // min-max 0.0–1.0
```

---

### Pivot

[](#pivot)

```
->pivot(rows: 'month', cols: 'region', value: 'revenue')
->pivot(rows: 'month', cols: 'region', value: 'revenue', aggregateFn: 'avg')

// Output:
// [
//   ['_row' => 'Jan', 'Nord' => 4200, 'Sud' => 3100, 'Est' => 1800],
//   ['_row' => 'Feb', 'Nord' => 5100, 'Sud' => 2900, 'Est' => 2200],
// ]
```

---

### Sorting &amp; slicing

[](#sorting--slicing)

```
->orderBy('amount', 'desc')
->orderBy([['status', 'asc'], ['amount', 'desc']])  // multi-key
->limit(10)
->limit(10, offset: 20)       // page 3 of 10-per-page
->topN(5, by: 'amount')       // shorthand for orderBy+limit
->bottomN(3, by: 'amount')
->rankBy('sales', direction: 'desc', as: 'rank')
```

---

### Structural operations

[](#structural-operations)

```
->distinct('productId')                // DISTINCT ON key
->reindex('id')                        // key by field → O(1) lookup
->pluck('id')                          // → [1, 2, 3, 4, 5]
->chunk(3)                             // → [[r0,r1,r2],[r3,r4,r5],[r6]]
->transpose()                          // flip rows ↔ columns
->sample(10)                           // random 10 rows
->sample(10, seed: 42)                 // reproducible
->fillGaps(
    key:     'month',
    series:  ['Jan','Feb','Mar','Apr'],
    default: ['revenue' => 0],
)
```

---

### Terminal operations

[](#terminal-operations)

```
->toArray()                            // execute + plain PHP array
->materialize()                        // execute + MaterializedCollection
->count()                              // row count
->partition("item['amount'] > 500")    // → PartitionResult
    ->pass()     // matching rows
    ->fail()     // non-matching rows
    ->passRate() // 0.0–1.0
```

---

Expression language
-------------------

[](#expression-language)

String expressions use ExpressionLanguage. The row is exposed as `item`:

```
->where("item['status'] == 'paid'")
->where("item['amount'] > 100 and item['region'] == 'Nord'")
->where("contains(item['email'], '@example.com')")
->where("length(item['name']) > 3")
->where("upper(item['tier']) == 'VIP'")
```

Built-in functions: `length`, `lower`, `upper`, `trim`, `abs`, `round`, `contains`.

**Closures are always supported** and run with zero ExpressionLanguage overhead:

```
->where(fn($r) => $r['amount'] > 100 && in_array($r['status'], ['paid', 'refunded']))
->select(fn($r) => [...$r, 'label' => strtoupper($r['name'])])
```

---

Pipeline branching
------------------

[](#pipeline-branching)

Pipelines are immutable — `pipe()` always returns a new instance. Branch freely:

```
$base = Algebra::from($orders)->where("item['status'] == 'paid'");

$byRegion  = $base->groupBy('region')->aggregate(['total' => 'sum(amount)']);
$top10     = $base->topN(10, by: 'amount');
$withOwner = $base->innerJoin($users, leftKey: 'userId', rightKey: 'id', as: 'owner');

// $base is unchanged — all three share the same filter step
```

---

Query planner
-------------

[](#query-planner)

The `QueryPlanner` automatically rewrites the declared operation order before execution. Filters are pushed before joins, redundant sorts eliminated, consecutive maps collapsed — without changing the result.

```
// Declared (suboptimal):
Algebra::from($orders)
    ->innerJoin($users, ...)    // join 1000 rows × 200 users
    ->where("item['status'] == 'paid'")  // then filter

// Optimized execution (planner reorders):
// 1. where   — reduce to ~333 rows  O(1000)
// 2. innerJoin — now O(333×200) instead of O(1000×200)
```

Inspect the plan:

```
$plan = Algebra::planner()->explain($collection->operations());
// [
//   'original'  => ['inner_join(...)', 'where(...)'],
//   'optimized' => ['where(...)', 'inner_join(...)'],
//   'changed'   => true,
//   'passes'    => ['PushFilterBeforeJoin', ...],
// ]
```

---

Execution log
-------------

[](#execution-log)

Every `MaterializedCollection` carries a per-operation execution log:

```
$result = Algebra::from($orders)
    ->where("item['status'] == 'paid'")
    ->pivot(rows: 'month', cols: 'region', value: 'amount')
    ->materialize();

foreach ($result->executionLog() as $step) {
    printf("%-50s %6.3fms  %d→%d rows\n",
        $step['signature'],
        $step['duration_ms'],
        $step['input_rows'],
        $step['output_rows'],
    );
}
printf("Total: %.3fms\n", $result->totalDurationMs());
```

---

Custom aggregates
-----------------

[](#custom-aggregates)

```
use Nalabdou\Algebra\Contract\AggregateInterface;

final class GeomeanAggregate implements AggregateInterface
{
    public function name(): string { return 'geomean'; }

    public function compute(array $values): float|null
    {
        if (empty($values)) { return null; }
        $product = array_product(array_map('abs', $values));
        return $product ** (1 / count($values));
    }
}

// Register once at bootstrap
Algebra::aggregates()->register(new GeomeanAggregate());

// Use anywhere
Algebra::from($data)
    ->groupBy('category')
    ->aggregate(['geo' => 'geomean(price)'])
    ->toArray();
```

---

Custom adapters
---------------

[](#custom-adapters)

```
use Nalabdou\Algebra\Contract\AdapterInterface;

final class DoctrineCollectionAdapter implements AdapterInterface
{
    public function supports(mixed $input): bool
    {
        return $input instanceof \Doctrine\Common\Collections\Collection;
    }

    public function toArray(mixed $input): array
    {
        return array_values($input->toArray());
    }
}
```

Register in a custom `CollectionFactory` or use the framework bundles:

- `nalabdou/algebra-symfony` — Symfony bundle with Doctrine, Profiler, Commands
- `nalabdou/algebra-laravel` — \[*Comming soon*\] Laravel Service Provider, Eloquent macros, Artisan
- `nalabdou/algebra-twig` — \[*Comming soon*\] All operations as Twig filters

---

Parallel execution
------------------

[](#parallel-execution)

```
$results = Algebra::parallel([
    'paid'    => Algebra::from($orders)->where("item['status'] == 'paid'"),
    'report'  => Algebra::from($sales)->groupBy('region')->aggregate([...]),
    'top10'   => Algebra::from($orders)->topN(10, by: 'amount'),
]);

$results['paid'];   // executed concurrently via PHP 8.1 Fibers
$results['report'];
$results['top10'];
```

---

Running the demos
-----------------

[](#running-the-demos)

```
composer install
php demo/01-basic-filters-and-joins.php
php demo/02-grouping-aggregation-pivot.php
php demo/03-window-functions.php
php demo/04-set-operations.php
php demo/05-structural-utilities.php
php demo/06-custom-aggregates-and-adapters.php
php demo/benchmark.php          # or: make benchmark
```

---

Running tests
-------------

[](#running-tests)

```
make install
make test          # all suites
make unit          # unit only
make integration   # integration only
make coverage      # HTML coverage report
make stan          # PHPStan level 5
make cs            # code style check
make ci            # cs + stan + test
```

---

Architecture
------------

[](#architecture)

```
src/
├── Algebra.php                        ← static entry point + singleton infrastructure
├── Contract/                          ← 7 interfaces — the public API surface
├── Collection/
│   ├── RelationalCollection.php       ← lazy, immutable, full fluent API
│   ├── MaterializedCollection.php     ← evaluated result + execution log
│   └── CollectionFactory.php          ← converts any input via adapters
├── Operation/
│   ├── Join/                          ← 6 operations (inner, left, semi, anti, cross, zip)
│   ├── Set/                           ← 4 operations (intersect, except, union, diffBy)
│   ├── Aggregate/                      ← 4 operations (aggregate, groupBy, tally, partition)
│   ├── Window/                         ← 3 operations (window dispatcher, movingAvg, normalize)
│   └── Utility/                        ← 13 operations (where/filter, select/map, orderBy/sort, limit/slice, pivot, sample, reindex, fillGaps, uniqueBy, chunk, extract, transpose)
├── Aggregate/
│   ├── AggregateRegistry.php          ← register + retrieve by name
│   ├── Math/                          ← count, sum, avg, min, max, median, stddev, variance, percentile
│   ├── Statistical/                    ← mode, count_distinct, ntile, cume_dist
│   ├── Positional/                     ← first, last
│   └── String/                         ← string_agg, bool_and, bool_or
├── Planner/
│   ├── QueryPlanner.php               ← runs optimization passes
│   └── Pass/                           ← 4 passes (filter pushdown, sort dedup, map collapse, pushFilterBeforeAntiJoin)
├── Expression/
│   ├── ExpressionEvaluator.php        ← ExpressionLanguage + fast-path
│   ├── Parser.php                      ← expression parser
│   ├── Lexer.php                       ← tokenizes expressions
│   ├── ExpressionCache.php            ← APCu-backed cache
│   ├── PropertyAccessor.php           ← dot-path resolver
│   └── Node/                           ← AST nodes for expressions
│       ├── Node.php
│       ├── ArrayNode.php
│       ├── BinaryNode.php
│       ├── CallNode.php
│       ├── LiteralNode.php
│       ├── NameNode.php
│       ├── PropertyNode.php
│       ├── SubscriptNode.php
│       ├── TernaryNode.php
│       └── UnaryNode.php
├── Adapter/                            ← array, generator, traversable
└── Result/
    └── PartitionResult.php            ← stores partitioned results + execution metadata

```

---

Versioning
----------

[](#versioning)

**MAJOR.MINOR.FIX** — Versioning follows this scheme:

- **MAJOR** – Incremented for breaking changes.
- **MINOR** – Incremented on a regular monthly release. Adds new features in a backward-compatible way.
- **FIX** – Incremented on demand for bug fixes, documentation updates, or minor improvements.

---

License
-------

[](#license)

MIT — [Nadim Al Abdou](https://github.com/nalabdou)

###  Health Score

39

—

LowBetter than 85% of packages

Maintenance83

Actively maintained with recent releases

Popularity9

Limited adoption so far

Community10

Small or concentrated contributor base

Maturity48

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~1 days

Total

2

Last Release

100d ago

PHP version history (2 changes)v1.0.0PHP ^8.2

1.1.0PHP &gt;=8.2

### Community

Maintainers

![](https://www.gravatar.com/avatar/e872d64f192da649961f598268ebc6a7622495868821b602fee29233ec58c70b?d=identicon)[nalabdou](/maintainers/nalabdou)

---

Top Contributors

[![nalabdou](https://avatars.githubusercontent.com/u/46465503?v=4)](https://github.com/nalabdou "nalabdou (13 commits)")

---

Tags

algebracollectionphpfiltercollectionjoinrelationalalgebrapivotgroup byaggregatewindow-functionsdata-pipeline

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/nalabdou-algebra-php/health.svg)

```
[![Health](https://phpackages.com/badges/nalabdou-algebra-php/health.svg)](https://phpackages.com/packages/nalabdou-algebra-php)
```

###  Alternatives

[graze/data-structure

Data collections and containers

12293.1k10](/packages/graze-data-structure)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
