Challenge - Performance optimization
Build an optimized report service with OPcache, benchmarks and eager loading.
Performance: three layers
In this lesson you improve performance on three layers. OPcache keeps bytecode in memory; you read it with opcache_get_status. A benchmark measures nanoseconds with hrtime(true) and memory with memory_get_usage(true). Eager loading solves the N+1 query problem with a single JOIN.
The N+1 problem: one query for a list, then one extra query per row's relation gives N+1 queries. Pulling all relations in one JOIN is eager loading and pays off especially for large lists.
Cache and benchmark combined
The Cache::get(string $key, callable $loader) pattern makes caching elegant: when the key is missing the loader runs and its result is stored; when present it is returned directly. A second call with the same key skips the loader; you prove the same instance is returned with ===.
The benchmark(callable $fn): array function measures time with hrtime(true) and memory with memory_get_usage(true), returning an array keyed by result, ms, bytes. Typical call: benchmark(fn() => $s->eagerReport()). Because results are deterministic we only print is_int checks.
Exercise Write Cache + benchmark + ReportService
The starting code has the ETL pipeline and ReflectionSerializer ready. Now add three pieces. The Cache class: private array $store = []; get(string $key, callable $loader) calls the loader when the key is missing and stores the result, otherwise returns it.
The benchmark(callable $fn): array function takes start and end with hrtime(true), divides by 1000000 and casts with (int) to milliseconds; it measures memory with memory_get_usage(true) and returns an array keyed by result, ms, bytes. ReportService takes a PDO; it creates products and categories tables, inserts one category and two products; eagerReport(): array returns all rows with a single JOIN query (no N+1). The demo runs with sqlite::memory:.
Show example solution
get(string $key, callable $loader) calls the loader when the key is missing and stores the value. The benchmark(callable $fn): array function measures time with hrtime(true) and memory with memory_get_usage(true); it returns an array keyed by result, ms, bytes. ReportService takes a PDO, creates products and categories tables, inserts two products; eagerReport() runs a single JOIN query to avoid the N+1 problem.
Example solution:
<?php
// ETL + Reflection + Performance: write Cache + benchmark + ReportService
function readRows(array $rows): Generator {
foreach ($rows as $row) {
yield $row;
}
}
function transform(iterable $rows, callable $fn): Generator {
foreach ($rows as $row) {
yield $fn($row);
}
}
function pipeline(array $rows, callable $fn): Generator {
yield from transform(readRows($rows), $fn);
}
function loadAll(iterable $rows): array {
$out = [];
foreach ($rows as $row) {
$out[] = $row;
}
return $out;
}
#[Attribute]
class Field {
public function __construct(public string $name = "") {}
}
class Product {
public function __construct(
#[Field("id")] public int $id,
#[Field("name")] public string $name,
) {}
}
class ReflectionSerializer {
public function serialize(object $obj): array {
$rc = new ReflectionClass($obj);
$out = [];
foreach ($rc->getProperties() as $prop) {
$attrs = $prop->getAttributes(Field::class);
if (empty($attrs)) continue;
$field = $attrs[0]->newInstance();
$key = $field->name !== "" ? $field->name : $prop->getName();
$out[$key] = $prop->getValue($obj);
}
return $out;
}
}
class Cache {
private array $store = [];
public function get(string $key, callable $loader) {
if (!isset($this->store[$key])) {
$this->store[$key] = $loader();
}
return $this->store[$key];
}
}
function benchmark(callable $fn): array {
$start = hrtime(true);
$result = $fn();
$elapsed = (int)((hrtime(true) - $start) / 1000000);
$mem = memory_get_usage(true);
return ["result" => $result, "ms" => $elapsed, "bytes" => $mem];
}
class ReportService {
private PDO $pdo;
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
$this->pdo->exec("CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, category_id INTEGER)");
$this->pdo->exec("CREATE TABLE categories (id INTEGER PRIMARY KEY, name TEXT)");
$this->pdo->prepare("INSERT INTO categories (name) VALUES (?)")->execute(["kirtasiye"]);
$this->pdo->prepare("INSERT INTO products (name, category_id) VALUES (?, ?)")->execute(["kale", 1]);
$this->pdo->prepare("INSERT INTO products (name, category_id) VALUES (?, ?)")->execute(["defter", 1]);
}
public function eagerReport(): array {
$sql = "SELECT p.name AS product, c.name AS category FROM products p JOIN categories c ON p.category_id = c.id ORDER BY p.id";
return $this->pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
}
}
// Demo:
$pdo = new PDO("sqlite::memory:");
$s = new ReportService($pdo);
$cache = new Cache();
$rows = $cache->get("report", fn() => $s->eagerReport());
$second = $cache->get("report", fn() => $s->eagerReport());
echo "rows: " . count($rows) . "\n";
echo "cached: " . ($rows === $second ? "1" : "0") . "\n";
$r = benchmark(fn() => $s->eagerReport());
echo "ms_int: " . (is_int($r["ms"]) ? "1" : "0") . "\n";
echo "bytes_int: " . (is_int($r["bytes"]) ? "1" : "0") . "\n";
Test yourself
-
Complete the memory_get_usage call
$bytes = ____(true);Show answer
$bytes = memory_get_usage(true); -
Match performance concepts
- OPcache
- Keeps bytecode in memory
- benchmark
- Measures time and memory
- eager loading
- Prevents N+1 queries
- memory_get_usage
- Returns memory usage
-
What is the N+1 query problem?
- Fetching N rows in a single query
- Running one main query plus one per row
- Joining multiple tables in one query
- Disabling the cache
Show answer
Running one main query plus one per row — Correct. N+1 means one main query plus one extra per row; it is prevented with eager loading.