php
59 lines · 8 steps
Streaming user activity with PHP generators
A generator paginates the users table and lazily yields each user enriched with their activity, keeping memory flat regardless of table size.
Explained by
highlit
1final class UserActivityStream
2{
3 public function __construct(
4 private readonly \PDO $db,
5 private readonly int $batchSize = 500
6 ) {
7 }
8
9 public function forActiveUsers(): \Generator
10 {
11 $offset = 0;
12
13 do {
14 $users = $this->fetchUserBatch($offset, $this->batchSize);
15 $ids = array_column($users, 'id');
16
17 if ($ids === []) {
18 return;
19 }
20
21 $activityByUser = $this->fetchActivityFor($ids);
22
23 foreach ($users as $user) {
24 $user['activity'] = $activityByUser[$user['id']] ?? [];
25 yield $user;
26 }
27
28 $offset += $this->batchSize;
29 } while (count($users) === $this->batchSize);
30 }
31
32 private function fetchUserBatch(int $offset, int $limit): array
33 {
34 $stmt = $this->db->prepare(
35 'SELECT id, email FROM users WHERE active = 1 ORDER BY id LIMIT :limit OFFSET :offset'
36 );
37 $stmt->bindValue(':limit', $limit, \PDO::PARAM_INT);
38 $stmt->bindValue(':offset', $offset, \PDO::PARAM_INT);
39 $stmt->execute();
40
41 return $stmt->fetchAll(\PDO::FETCH_ASSOC);
42 }
43
44 private function fetchActivityFor(array $userIds): array
45 {
46 $placeholders = implode(',', array_fill(0, count($userIds), '?'));
47 $stmt = $this->db->prepare(
48 "SELECT user_id, action, created_at FROM activity_log WHERE user_id IN ($placeholders)"
49 );
50 $stmt->execute($userIds);
51
52 $grouped = [];
53 foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) as $row) {
54 $grouped[$row['user_id']][] = $row;
55 }
56
57 return $grouped;
58 }
59}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Generators let you process arbitrarily large result sets while holding only one batch in memory at a time.
- 2Fetching related data once per batch and grouping it in a hash map avoids the N+1 query trap.
- 3Keyset or offset pagination combined with yielding turns a huge query into a streamable pipeline.
Related explainers
javascript
export function buildPaginationUrl(baseUrl, { page, perPage, filters = {}, sort } = {}) { const url = new URL(baseUrl); const params = url.searchParams;
Building and parsing pagination URLs
url-parsing
query-string
pagination
Intermediate
8 steps
php
<?php namespace App\Casts;
How a custom Eloquent cast wraps an Address in Laravel
value-objects
serialization
data-mapping
Intermediate
7 steps
rust
use std::collections::HashMap; #[derive(Debug, Clone)] struct Record {
Batched aggregation with fold in Rust
iterators
fold
hashmap
Intermediate
9 steps
php
use Illuminate\Support\Facades\Route; use App\Http\Controllers\Admin\DashboardController; use App\Http\Controllers\Admin\UserController; use App\Http\Controllers\Admin\ReportController;
How nested route groups compose in Laravel
routing
middleware
authorization
Intermediate
8 steps
php
final class TokenBucketLimiter { private float $tokens; private float $lastRefill;
How a token bucket rate limiter works
rate-limiting
token-bucket
throttling
Intermediate
7 steps
ruby
module Api module V2 class ArticlesController < Api::BaseController before_action :set_article, only: %i[show update destroy]
Building a versioned JSON API controller in Rails
rest-api
serialization
pagination
Intermediate
10 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/streaming-user-activity-with-php-generators-explained-php-1dce/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.