phplyr

Guides

Porting an R script

This page takes a typical R analysis of the schools data and translates it to PHP with phplyr and phit. The analysis reads a CSV file, cleans and reshapes it, summarizes it, and fits a linear, a logistic, and a random-intercept model. Both scripts ran when the documentation was built. The output under each script is what it printed.

The data is schools.csv. The R script needs dplyr, tidyr, and lme4. The PHP script needs phplyr and the phit extension for the models.

R

suppressPackageStartupMessages({
  library(dplyr)
  library(tidyr)
  library(lme4)
})

schools <- as_tibble(read.csv("schools.csv"))

# Clean: drop incomplete rows, band study time, compute the gain.
clean <- schools |>
  filter(!is.na(score), study_hours > 0) |>
  mutate(
    hours_band = case_when(study_hours < 4 ~ "low", study_hours < 8 ~ "mid", .default = "high"),
    gain = score - prior_score,
    passed = as.integer(score >= 75)
  )

# Reshape: one row per student and test.
long <- clean |>
  mutate(student = row_number()) |>
  select(student, school, prior = prior_score, final = score) |>
  pivot_longer(c(prior, final), names_to = "test", values_to = "points")

long |> group_by(test) |> summarise(mean = mean(points), sd = sd(points))

# Summarise: students per school and band, then the schools with the largest gains.
clean |>
  count(school, hours_band) |>
  pivot_wider(names_from = hours_band, values_from = n, values_fill = 0) |>
  head(3)

clean |>
  group_by(school) |>
  summarise(n = n(), mean_gain = mean(gain)) |>
  arrange(desc(mean_gain)) |>
  head(3)

# Model: linear, logistic, and random-intercept fits.
round(coef(lm(score ~ study_hours + prior_score, data = clean)), 4)
round(coef(glm(passed ~ study_hours + prior_score, family = binomial, data = clean)), 4)
round(fixef(lmer(score ~ study_hours + prior_score + (1 | school), data = clean)), 4)
# A tibble: 2 × 3
  test   mean    sd
  <chr> <dbl> <dbl>
1 final  72.0  10.3
2 prior  59.8  12.4
# A tibble: 3 × 4
  school     high   low   mid
  <chr>     <int> <int> <int>
1 school-01     7     4     7
2 school-02     8     8     8
3 school-03     5     3     7
# A tibble: 3 × 3
  school        n mean_gain
  <chr>     <int>     <dbl>
1 school-07    19      24.3
2 school-10    25      14.1
3 school-02    24      13.6
(Intercept) study_hours prior_score 
    45.3014      1.5691      0.2730 
(Intercept) study_hours prior_score 
    -6.9865      0.5688      0.0395 
(Intercept) study_hours prior_score 
    46.6627      1.6141      0.2453

PHP

$schools = DataFrame::fromCsv('schools.csv');

// Clean: drop incomplete rows, band study time, compute the gain.
$clean = $schools |>
    filter(col('score')->isNa()->not(), col('study_hours')->gt(0)) |>
    mutate(
        hours_band: case_when([col('study_hours')->lt(4), 'low'], [col('study_hours')->lt(8), 'mid'], default: 'high'),
        gain: col('score')->sub(col('prior_score')),
        passed: if_else(col('score')->ge(75), 1, 0),
    );

// Reshape: one row per student and test.
$long = $clean |>
    mutate(student: row_number()) |>
    select('student', 'school', ['prior' => 'prior_score', 'final' => 'score']) |>
    pivot_longer(['prior', 'final'], namesTo: 'test', valuesTo: 'points');

echo $long |> group_by('test') |> summarize(mean: mean('points'), sd: sd('points')), "\n";

// Summarise: students per school and band, then the schools with the largest gains.
echo $clean |>
    count('school', 'hours_band') |>
    pivot_wider(namesFrom: 'hours_band', valuesFrom: 'n', valuesFill: 0) |>
    head(3), "\n";

echo $clean |>
    group_by('school') |>
    summarize(n: n(), mean_gain: mean('gain')) |>
    arrange(desc('mean_gain')) |>
    head(3), "\n";

// Model: linear, logistic, and random-intercept fits.
$coef = fn (array $fit) => implode('  ', array_map(
    fn ($name, $b) => $name.' '.\round($b, 4),
    $fit['columns'],
    $fit['coefficients'],
));
echo $coef($clean |> lm('score ~ study_hours + prior_score')), "\n";
echo $coef($clean |> glm('passed ~ study_hours + prior_score', family: 'binomial')), "\n";
echo $coef($clean |> lmer('score ~ study_hours + prior_score + (1 | school)')), "\n";
$schools = DataFrame::fromCsv('schools.csv');

// Clean: drop incomplete rows, band study time, compute the gain.
$clean = phplyr($schools)
    ->filter(col('score')->isNa()->not(), col('study_hours')->gt(0))
    ->mutate(
        hours_band: case_when([col('study_hours')->lt(4), 'low'], [col('study_hours')->lt(8), 'mid'], default: 'high'),
        gain: col('score')->sub(col('prior_score')),
        passed: if_else(col('score')->ge(75), 1, 0),
    )
    ->get();

// Reshape: one row per student and test.
$long = phplyr($clean)
    ->mutate(student: row_number())
    ->select('student', 'school', ['prior' => 'prior_score', 'final' => 'score'])
    ->pivotLonger(['prior', 'final'], namesTo: 'test', valuesTo: 'points')
    ->get();

echo phplyr($long)->groupBy('test')->summarize(mean: mean('points'), sd: sd('points')), "\n";

// Summarise: students per school and band, then the schools with the largest gains.
echo phplyr($clean)
    ->count('school', 'hours_band')
    ->pivotWider(namesFrom: 'hours_band', valuesFrom: 'n', valuesFill: 0)
    ->head(3), "\n";

echo phplyr($clean)
    ->groupBy('school')
    ->summarize(n: n(), mean_gain: mean('gain'))
    ->arrange(desc('mean_gain'))
    ->head(3), "\n";

// Model: linear, logistic, and random-intercept fits.
$coef = fn (array $fit) => implode('  ', array_map(
    fn ($name, $b) => $name.' '.\round($b, 4),
    $fit['columns'],
    $fit['coefficients'],
));
echo $coef(phplyr($clean)->lm('score ~ study_hours + prior_score')), "\n";
echo $coef(phplyr($clean)->glm('passed ~ study_hours + prior_score', family: 'binomial')), "\n";
echo $coef(phplyr($clean)->lmer('score ~ study_hours + prior_score + (1 | school)')), "\n";
# A tibble: 2 × 3
  test   mean    sd
  <chr> <dbl> <dbl>
1 final  72.0  10.3
2 prior  59.8  12.4
# A tibble: 3 × 4
  school     high   low   mid
  <chr>     <int> <int> <int>
1 school-01     7     4     7
2 school-02     8     8     8
3 school-03     5     3     7
# A tibble: 3 × 3
  school        n mean_gain
  <chr>     <int>     <dbl>
1 school-07    19      24.3
2 school-10    25      14.1
3 school-02    24      13.6
(Intercept) 45.3014  study_hours 1.5691  prior_score 0.273
(Intercept) -6.9865  study_hours 0.5688  prior_score 0.0395
(Intercept) 46.6627  study_hours 1.6141  prior_score 0.2453

What changed in translation

  • Loading. read.csv() becomes DataFrame::fromCsv(). phibble infers column types like readr.
  • Pipes. On PHP 8.5, R’s |> stays |>. Each function returns a closure that the pipe applies. On PHP 8.3 and 8.4, the pipeline becomes a method chain: phplyr($t)->filter(...), ending with ->get().
  • Column references. Bare names become strings, or col('x') inside expressions. score - prior_score becomes col('score')->sub(col('prior_score')). A row closure, fn ($r) => $r->score - $r->prior_score, also works.
  • Conditions. case_when(x < 4 ~ "low", .default = "high") becomes case_when([col('x')->lt(4), 'low'], default: 'high').
  • Renaming in select. select(prior = prior_score) becomes select(['prior' => 'prior_score']).
  • Column sets. c(prior, final) becomes ['prior', 'final'].
  • Models. lm(), glm(), and lmer() take the same formulas. They return arrays with columns (R’s coefficient names), coefficients, standard errors, and fit statistics instead of model objects, so coef() and fixef() become array reads.
  • Printing. DataFrames print in tibble’s format. R rounds printed numbers to significant digits; phibble does the same.

Because the PHP pipeline uses col() expressions instead of closures, it also runs unchanged on a database table from Phibble\tbl(). See Databases and expressions.

From phpstats/phplyr@d319dc5.