Building a Recently Viewed Products Tracker with Redis Sorted Sets
"Recently viewed products" is one of those features that looks trivial until you think about scale: every page view is a write, the list needs to be ordered by recency, capped in length, and fast to read. A database table with timestamps and a cleanup job works, but Redis sorted sets are built for exactly this shape of problem. Here's how to build it in a PhpArchitect storefront.
Step 1: Why a sorted set
A Redis sorted set stores members with a score, and keeps them ordered by that score automatically. If we use the view timestamp as the score and the product ID as the member, "most recently viewed" falls out of the data structure for free, no ORDER BY, no index tuning.
Step 2: Recording a view
namespace App\Services;
use Illuminate\Support\Facades\Redis;
class RecentlyViewedService
{
private const MAX_ITEMS = 20;
public function record(int $userIdentifier, int $productId): void
{
$key = $this->key($userIdentifier);
Redis::zadd($key, now()->timestamp, $productId);
// Trim to the most recent MAX_ITEMS entries
Redis::zremrangebyrank($key, 0, -self::MAX_ITEMS - 1);
// Expire the whole set after 30 days of inactivity
Redis::expire($key, 60 * 60 * 24 * 30);
}
private function key(int $userIdentifier): string
{
return "recently_viewed:{$userIdentifier}";
}
}
zadd either inserts a new member or updates the score of an existing one, so viewing the same product twice just bumps it to the top instead of creating a duplicate entry. zremrangebyrank trims anything beyond the newest 20 entries in a single call.
Step 3: Recording the view in a controller
namespace App\Http\Controllers;
use App\Models\Product;
use App\Services\RecentlyViewedService;
class ProductController extends Controller
{
public function show(Product $product, RecentlyViewedService $recentlyViewed)
{
$recentlyViewed->record(
userIdentifier: auth()->id() ?? crc32(session()->getId()),
productId: $product->id
);
return view('products.show', compact('product'));
}
}
Guests get tracked too, keyed off a hashed session ID, so the feature works before someone logs in.
Step 4: Reading the list back
public function recent(int $userIdentifier): \Illuminate\Support\Collection
{
$ids = Redis::zrevrange($this->key($userIdentifier), 0, self::MAX_ITEMS - 1);
if (empty($ids)) {
return collect();
}
$products = Product::whereIn('id', $ids)->get()->keyBy('id');
// Preserve Redis' recency order, since whereIn() doesn't guarantee it
return collect($ids)
->map(fn ($id) => $products->get($id))
->filter();
}
zrevrange returns members from highest score to lowest, which is exactly "most recent first." Because SQL's whereIn() doesn't preserve order, we re-sort the Eloquent results against the original Redis ordering.
Step 5: Displaying it in Blade
@if ($recentlyViewed->isNotEmpty())
<section>
<h2>Recently Viewed</h2>
<div class="grid grid-cols-4 gap-4">
@foreach ($recentlyViewed as $product)
<x-product-card :product="$product" />
@endforeach
</div>
</section>
@endif
Why not just a database table?
You could do this with a product_views table and a scheduled cleanup job, and for low-traffic apps that's fine. But at real storefront traffic, every product page view becoming an INSERT plus a periodic DELETE job adds real load to your primary database for data that's inherently disposable and time-boxed. Redis is already built to expire and cap keys like this, so the sorted set approach keeps this entirely off your relational database.