phpolars

Guide

Getting started

Installing

phpolars runs on PHP 8.3, 8.4, and 8.5, non-thread-safe (NTS) builds, on Linux and macOS. Install it with PIE, PHP’s extension installer:

pie install phpstats/phpolars

On Linux on x86_64 and arm64 with glibc 2.34 or later, PIE downloads a prebuilt binary. On other platforms, including macOS, it builds the extension from source, which needs a stable Rust toolchain and libclang and takes several minutes. PIE then enables the extension in php.ini. Under PHP-FPM, restart FPM afterwards so that its workers load the extension. Deployment covers servers, Laravel Forge, Laravel Herd, and manual installs.

Checking the installation

php -r 'echo phpversion("php_polars"), PHP_EOL;'

The command prints the installed version, such as 0.2.1. It checks the CLI. PHP-FPM reads its own php.ini, so check a web request separately, for example with extension_loaded('php_polars') in a page.

Imports

Classes live in Phpolars, and functions are namespaced, so import them:

use Phpolars\{DataFrame, Series};
use function Phpolars\{col, lit, when, read_csv};

A first query

$sales = read_csv('sales.csv');

echo $sales
    ->select(
        'id',
        'product',
        col('units')->fillNull(0),
        ['revenue' => col('units')->mul(col('price'))->round(2)],
        when(col('price')->ge(5))->then(lit('premium'))->otherwise(lit('standard'))->alias('tier'),
    )
    ->head(5);
shape: (5, 5)
┌─────┬─────────┬───────┬─────────┬──────────┐
│ id  ┆ product ┆ units ┆ revenue ┆ tier     │
│ --- ┆ ---     ┆ ---   ┆ ---     ┆ ---      │
│ i64 ┆ str     ┆ i64   ┆ f64     ┆ str      │
╞═════╪═════════╪═══════╪═════════╪══════════╡
│ 1   ┆ banana  ┆ 10    ┆ 70.3    ┆ premium  │
│ 2   ┆ cherry  ┆ 0     ┆ 0.0     ┆ standard │
│ 3   ┆ apple   ┆ 2     ┆ 7.68    ┆ standard │
│ 4   ┆ apple   ┆ 12    ┆ 88.08   ┆ premium  │
│ 5   ┆ apple   ┆ 1     ┆ 6.05    ┆ premium  │
└─────┴─────────┴───────┴─────────┴──────────┘

Strings passed where py-polars expects an expression input, such as select(), name columns. Use lit() for a string value there. In comparisons and arithmetic, strings are literals: col('store')->ne('S1').

Filtering

$sales = read_csv('sales.csv');

// Several predicates are combined with and; null counts as false.
echo $sales->filter(
    col('product')->isIn(['apple', 'cherry']),
    col('units')->isBetween(5, 8),
    col('store')->ne('S1'),
)->head(5);
shape: (5, 6)
┌─────┬────────────┬───────┬─────────┬───────┬───────┐
│ id  ┆ date       ┆ store ┆ product ┆ units ┆ price │
│ --- ┆ ---        ┆ ---   ┆ ---     ┆ ---   ┆ ---   │
│ i64 ┆ str        ┆ str   ┆ str     ┆ i64   ┆ f64   │
╞═════╪════════════╪═══════╪═════════╪═══════╪═══════╡
│ 12  ┆ 2024-03-17 ┆ S2    ┆ apple   ┆ 8     ┆ 3.85  │
│ 16  ┆ 2024-02-24 ┆ S3    ┆ apple   ┆ 8     ┆ 5.07  │
│ 20  ┆ 2024-03-26 ┆ S4    ┆ cherry  ┆ 7     ┆ 5.72  │
│ 22  ┆ 2024-03-05 ┆ S2    ┆ apple   ┆ 5     ┆ 8.23  │
│ 24  ┆ 2024-03-26 ┆ S3    ┆ cherry  ┆ 6     ┆ 2.2   │
└─────┴────────────┴───────┴─────────┴───────┴───────┘

Grouping

$sales = read_csv('sales.csv');

echo $sales->groupBy(['store', 'product'], maintainOrder: true)
    ->agg(
        len()->alias('rows'),
        col('units')->sum(),
        col('units')->nullCount()->alias('missing_units'),
        col('price')->max()->alias('top_price'),
    )
    ->sort(['store', 'product'])
    ->head(6), "\n";

// Shorthands aggregate every non-key column.
echo $sales->select('store', 'units', 'price')->groupBy('store')->mean()->sort('store');
shape: (6, 6)
┌───────┬─────────┬──────┬───────┬───────────────┬───────────┐
│ store ┆ product ┆ rows ┆ units ┆ missing_units ┆ top_price │
│ ---   ┆ ---     ┆ ---  ┆ ---   ┆ ---           ┆ ---       │
│ str   ┆ str     ┆ u32  ┆ i64   ┆ u32           ┆ f64       │
╞═══════╪═════════╪══════╪═══════╪═══════════════╪═══════════╡
│ S1    ┆ apple   ┆ 17   ┆ 98    ┆ 0             ┆ 7.23      │
│ S1    ┆ banana  ┆ 15   ┆ 81    ┆ 0             ┆ 8.98      │
│ S1    ┆ cherry  ┆ 12   ┆ 90    ┆ 0             ┆ 8.07      │
│ S1    ┆ date    ┆ 13   ┆ 109   ┆ 0             ┆ 8.57      │
│ S2    ┆ apple   ┆ 11   ┆ 47    ┆ 0             ┆ 8.23      │
│ S2    ┆ banana  ┆ 14   ┆ 69    ┆ 1             ┆ 6.92      │
└───────┴─────────┴──────┴───────┴───────────────┴───────────┘
shape: (4, 3)
┌───────┬──────────┬──────────┐
│ store ┆ units    ┆ price    │
│ ---   ┆ ---      ┆ ---      │
│ str   ┆ f64      ┆ f64      │
╞═══════╪══════════╪══════════╡
│ S1    ┆ 6.631579 ┆ 4.010702 │
│ S2    ┆ 5.282609 ┆ 4.237843 │
│ S3    ┆ 6.282609 ┆ 4.877347 │
│ S4    ┆ 4.883721 ┆ 4.074186 │
└───────┴──────────┴──────────┘

groupBy() follows py-polars and does not keep group order unless maintainOrder: true is passed.

Lazy queries

A DataFrame runs each method immediately. A LazyFrame records operations and runs them, optimized, when collect() is called. scan_csv() starts a lazy query from a file, so Polars reads only the columns and rows that the query needs:

$query = scan_csv('sales.csv')
    ->filter(col('units')->gt(5))
    ->groupBy('store')
    ->agg(col('units')->sum())
    ->sort('store');

echo $query->explain(), "\n\n";
echo $query->collect();
SORT BY [col("store")]
  AGGREGATE[maintain_order: false]
    [col("units").sum()] BY [col("store")]
    FROM
    Csv SCAN [sales.csv]
    PROJECT 2/6 COLUMNS
    SELECTION: col("units") > 5
    ESTIMATED ROWS: 201

shape: (4, 2)
┌───────┬───────┐
│ store ┆ units │
│ ---   ┆ ---   │
│ str   ┆ i64   │
╞═══════╪═══════╡
│ S1    ┆ 335   │
│ S2    ┆ 199   │
│ S3    ┆ 230   │
│ S4    ┆ 136   │
└───────┴───────┘

Missing values

phpolars follows Polars: a missing value is null, aggregates such as sum() and mean() skip it, and null sorts first. phibble and phplyr follow R and dplyr instead. phibble and phplyr compares the two.

From phpstats/phpolars@8487062.