Guide
Getting started
Loading phibble
Install phibble with Composer, and load Composer’s autoloader once at the top of a script. Frameworks such as Laravel load it already.
composer require phpstats/phibbleThe examples on this site import the DataFrame class and phibble’s functions like this:
require __DIR__.'/vendor/autoload.php';
use Phibble\DataFrame;
use function Phibble\{tibble, tribble};
use function Phibble\Expr\{col, desc, mean, n};Phibble\Expr has functions named min, max, sum, round, abs, log, floor, str_pad, str_split, str_replace, and a few others that share names with PHP built-ins. In a file that imports them, a bare round($x) calls phibble’s expression helper. Write \round($x) for PHP’s function.
Building data frames
tibble() builds a data frame column by column. Each named argument is a column. A list gives one value per row, and a single value is repeated for every row. A closure receives the data frame built so far, so a column can be computed from earlier ones.
$scores = tibble(
student: ['ana', 'ben', 'cy'],
score: [81.5, 74.0, null],
passed: fn (DataFrame $t) => array_map(fn ($s) => $s === null ? null : $s >= 75, $t['score']),
term: 'fall',
);
echo $scores;# A tibble: 3 × 4
student score passed term
<chr> <dbl> <lgl> <chr>
1 ana 81.5 TRUE fall
2 ben 74 FALSE fall
3 cy NA NA fall
tribble() takes the column names first and then one list per row, which is easier to read for small literal tables:
$sites = tribble(
['site', 'lat', 'opened'],
['north', 44.9, 2019],
['south', 41.2, 2021],
);
echo $sites;# A tibble: 2 × 3
site lat opened
<chr> <dbl> <int>
1 north 44.9 2019
2 south 41.2 2021
DataFrame::fromRows() takes a list of associative arrays. Keys missing from a row are NA. DataFrame::fromColumns() takes an array of columns and accepts explicit types.
$rows = DataFrame::fromRows([
['id' => 1, 'dose' => 2.5, 'arm' => 'a'],
['id' => 2, 'arm' => 'b'],
]);
$cols = DataFrame::fromColumns(['id' => [1, 2], 'arm' => ['a', 'b']], types: ['arm' => 'factor']);
echo $rows, "\n\n", $cols;# A tibble: 2 × 3
id dose arm
<int> <dbl> <chr>
1 1 2.5 a
2 2 NA b
# A tibble: 2 × 2
id arm
<int> <fct>
1 1 a
2 2 b
DataFrame::fromCsv() reads a CSV file and guesses each column’s type. The input and output guide covers its options.
Printing
echo $df prints the data frame the way tibble does. The header gives the size, each column shows its type, and data frames with more than 20 rows show the first 10. Numbers show three significant digits and NA prints as NA (or <NA> in text columns).
$penguins = DataFrame::fromCsv('penguins.csv');
echo $penguins;# A tibble: 344 × 8
species island bill_len bill_dep flipper_len body_mass sex year
<chr> <chr> <dbl> <dbl> <int> <int> <chr> <int>
1 Adelie Torgersen 39.1 18.7 181 3750 male 2007
2 Adelie Torgersen 39.5 17.4 186 3800 female 2007
3 Adelie Torgersen 40.3 18 195 3250 female 2007
4 Adelie Torgersen NA NA NA NA <NA> 2007
5 Adelie Torgersen 36.7 19.3 193 3450 female 2007
6 Adelie Torgersen 39.3 20.6 190 3650 male 2007
7 Adelie Torgersen 38.9 17.8 181 3625 female 2007
8 Adelie Torgersen 39.2 19.6 195 4675 male 2007
9 Adelie Torgersen 34.1 18.1 193 3475 <NA> 2007
10 Adelie Torgersen 42 20.2 190 4250 <NA> 2007
# ℹ 334 more rows
glimpse() returns one line per column, which suits wide data frames:
$penguins = DataFrame::fromCsv('penguins.csv');
echo $penguins->glimpse();Rows: 344
Columns: 8
$ species <chr> "Adelie", "Adelie", "Adelie", "Adelie", "Adelie", "Adelie"…
$ island <chr> "Torgersen", "Torgersen", "Torgersen", "Torgersen", "Torge…
$ bill_len <dbl> 39.1, 39.5, 40.3, NA, 36.7, 39.3, 38.9, 39.2, 34.1, 42.0, …
$ bill_dep <dbl> 18.7, 17.4, 18.0, NA, 19.3, 20.6, 17.8, 19.6, 18.1, 20.2, …
$ flipper_len <int> 181, 186, 195, NA, 193, 190, 181, 195, 193, 190, 186, 180,…
$ body_mass <int> 3750, 3800, 3250, NA, 3450, 3650, 3625, 4675, 3475, 4250, …
$ sex <chr> "male", "female", "female", NA, "female", "male", "female"…
$ year <int> 2007, 2007, 2007, 2007, 2007, 2007, 2007, 2007, 2007, 2007…
format(n: 30, width: 120) prints more rows or a wider table.
Types and NA
Each column has one type from the Phibble\Type enum: Float, Int, Bool, String, Factor, Date, DateTime, or List. Types are inferred from the values, and PHP null is NA in every type. The types guide describes inference, casts, and factors.
Reading values
A column reads as a PHP list with $df->name, $df['name'], or $df->column('name'). Rows are Phibble\Row objects, which read values as properties or array keys. Iterating a data frame yields its rows. An unknown column name throws Phibble\Exception\ColumnNotFound; names are never matched partially.
$df = tibble(x: [1, 2, 3], y: ['a', 'b', 'c']);
var_dump($df->x === $df['x']);
echo json_encode($df->column('y')), "\n";
$row = $df->row(1);
echo $row->x, ' ', $row['y'], "\n";
foreach ($df as $i => $row) {
echo "{$i}: {$row->y}\n";
}
try {
$df->z;
} catch (Phibble\Exception\ColumnNotFound $e) {
echo $e->getMessage(), "\n";
}bool(true)
["a","b","c"]
2 b
0: a
1: b
2: c
Column 'z' not found. Available columns: x, y.
Verbs
DataFrames have the dplyr verbs as methods: select(), filter(), mutate(), rename(), arrange(), groupBy(), summarize(), distinct(), slice(), head(), and join(). Each returns a new data frame.
filter() and mutate() accept a row closure, which receives a Row and returns a value. arrange() takes column names, or [name, 'desc'] pairs.
$penguins = DataFrame::fromCsv('penguins.csv');
$heavy = $penguins
->select(['species', 'island', 'body_mass', 'year'])
->filter(fn ($r) => $r->body_mass !== null && $r->body_mass > 5500)
->mutate(['kg' => fn ($r) => $r->body_mass / 1000])
->arrange([['body_mass', 'desc']])
->head(5);
echo $heavy;# A tibble: 5 × 5
species island body_mass year kg
<chr> <chr> <int> <int> <dbl>
1 Gentoo Biscoe 6300 2007 6.3
2 Gentoo Biscoe 6050 2007 6.05
3 Gentoo Biscoe 6000 2008 6
4 Gentoo Biscoe 6000 2009 6
5 Gentoo Biscoe 5950 2008 5.95
The same verbs accept expressions built with col() and the helper functions. Expressions are evaluated a whole column at a time, and they are the form that SQL and Polars backends can translate.
$penguins = DataFrame::fromCsv('penguins.csv');
$heavy = $penguins
->select(['species', 'island', 'body_mass', 'year'])
->filter(col('body_mass')->gt(5500))
->mutate(['kg' => col('body_mass')->div(1000)])
->arrange([desc(col('body_mass'))])
->head(5);
echo $heavy;# A tibble: 5 × 5
species island body_mass year kg
<chr> <chr> <int> <int> <dbl>
1 Gentoo Biscoe 6300 2007 6.3
2 Gentoo Biscoe 6050 2007 6.05
3 Gentoo Biscoe 6000 2008 6
4 Gentoo Biscoe 6000 2009 6
5 Gentoo Biscoe 5950 2008 5.95
Groups
groupBy() records grouping columns on the data frame. summarize(), or its alias summarise(), returns one row per group, with groups in sorted order and NA last, as dplyr does. With several grouping columns, the result keeps all but the last.
$penguins = DataFrame::fromCsv('penguins.csv');
$summary = $penguins
->filter(col('body_mass')->isNa()->not())
->groupBy(['species', 'sex'])
->summarize([
'n' => n(),
'mass' => mean(col('body_mass')),
'flipper' => median(col('flipper_len')),
]);
echo $summary;# A tibble: 8 × 5
# Groups: species [3]
species sex n mass flipper
<chr> <chr> <int> <dbl> <dbl>
1 Adelie female 73 3369. 188
2 Adelie male 73 4043. 193
3 Adelie <NA> 5 3540 186
4 Chinstrap female 34 3527. 192
5 Chinstrap male 34 3939. 200.
6 Gentoo female 58 4680. 212
7 Gentoo male 61 5485. 221
8 Gentoo <NA> 4 4588. 216
In a grouped data frame, filter() and mutate() work within each group. An aggregate inside mutate() is computed per group and repeated for each of the group’s rows:
$penguins = DataFrame::fromCsv('penguins.csv')->dropNa(['body_mass']);
$centered = $penguins
->groupBy(['species'])
->mutate(['vs_species' => col('body_mass')->sub(mean(col('body_mass')))])
->select(['species', 'body_mass', 'vs_species'])
->head(4);
echo $centered;# A tibble: 4 × 3
# Groups: species [1]
species body_mass vs_species
<chr> <int> <dbl>
1 Adelie 3750 49.3
2 Adelie 3800 99.3
3 Adelie 3250 -451.
4 Adelie 3450 -251.
A row closure receives the row’s group as its second argument: fn (Row $r, DataFrame $group) => ....
By default, groups are the combinations that occur in the data. With groupBy($names, drop: false), as with dplyr’s .drop = FALSE, a factor key also yields a group for each level that has no rows. Such a group has zero rows, so n() is 0 and sum() is 0.
$penguins = DataFrame::fromCsv('penguins.csv')
->asFactor('species')
->filter(col('species')->ne('Chinstrap'));
echo $penguins->groupBy(['species'], drop: false)->summarize(['n' => n()]);# A tibble: 3 × 2
species n
<fct> <int>
1 Adelie 152
2 Chinstrap 0
3 Gentoo 124
Distinct rows and joins
distinct() keeps the first row of each combination of the given columns. join() takes the other table, the key columns, and the join type: inner, left, right, full, semi, anti, or cross. Joins follow dplyr 1.1, including its column naming and the rule that NA keys match each other.
$penguins = DataFrame::fromCsv('penguins.csv');
$islands = tribble(
['island', 'area_km2'],
['Biscoe', 6.1],
['Dream', 0.9],
);
$joined = $penguins
->distinct(['species', 'island'])
->join($islands, ['island'], 'left');
echo $joined;# A tibble: 5 × 3
species island area_km2
<chr> <chr> <dbl>
1 Adelie Torgersen NA
2 Adelie Biscoe 6.1
3 Adelie Dream 0.9
4 Gentoo Biscoe 6.1
5 Chinstrap Dream 0.9
Besides equality keys, a join condition can compare columns, as dplyr’s join_by() does. [$left, $op, $right] with >=, >, <=, or < keeps the pairs that satisfy the comparison, and all conditions must hold. One ['closest', $left, $op, $right] condition keeps, for each row of x, only the y rows with the nearest value: the largest for >= and >, the smallest for <= and <. dplyr’s between(), within(), and overlaps() are pairs of comparisons. In a rolling join, the columns used in comparisons are kept from both tables:
// Each order gets the latest price set on or before its date.
$orders = tribble(
['order', 'item', 'day'],
[1, 'tea', 3],
[2, 'tea', 9],
[3, 'coffee', 4],
);
$prices = tribble(
['item', 'from_day', 'price'],
['tea', 1, 2.5],
['tea', 5, 2.75],
['coffee', 1, 4.0],
['coffee', 6, 4.5],
);
echo $orders->join($prices, ['item', ['closest', 'day', '>=', 'from_day']], 'left');# A tibble: 3 × 5
order item day from_day price
<int> <chr> <int> <int> <dbl>
1 1 tea 3 1 2.5
2 2 tea 9 5 2.75
3 3 coffee 4 1 4
Immutability
Methods never change the data frame they are called on:
$df = tibble(x: [1, 2, 3]);
$doubled = $df->mutate(['x' => col('x')->mul(2)]);
echo json_encode($df->x), ' ', json_encode($doubled->x), "\n";[1,2,3] [2,4,6]
Missing values
PHP null is a missing value. Arithmetic with it gives null, and an aggregate over a column that holds it gives null unless na_rm: true is passed:
$orders = tibble(total: [20.0, null, 35.0]);
echo $orders->mutate(['with_tax' => col('total')->mul(1.1)]), "\n";
echo $orders->summarize([
'mean' => mean(col('total')),
'mean_known' => mean(col('total'), na_rm: true),
]);# A tibble: 3 × 2
total with_tax
<dbl> <dbl>
1 20 22
2 NA NA
3 35 38.5
# A tibble: 1 × 2
mean mean_known
<dbl> <dbl>
1 NA 27.5
Method chains and the pipe operator
Every verb returns a new table, so verbs chain as methods on PHP 8.3, 8.4, and 8.5. PHP 8.5’s |> passes a value to a callable, and arrow functions let the same pipeline use it. phplyr provides verbs that return such callables, so a phplyr pipeline needs no arrow functions.
$penguins = DataFrame::fromCsv('penguins.csv');
$counts = $penguins |>
(fn (DataFrame $f) => $f->groupBy(['island'])) |>
(fn (DataFrame $f) => $f->summarize(['n' => n()])) |>
(fn (DataFrame $f) => $f->arrange([desc(col('n'))]));
echo $counts;$penguins = DataFrame::fromCsv('penguins.csv');
$counts = $penguins
->groupBy(['island'])
->summarize(['n' => n()])
->arrange([desc(col('n'))]);
echo $counts;# A tibble: 3 × 2
island n
<chr> <int>
1 Biscoe 168
2 Dream 124
3 Torgersen 52
From phpstats/phibble@36d0f3f.