$ lexprog.com

// notes from an old coder -- php, databases, and the occasional rant

[November 10, 2025] ClickHouse

ClickHouse and Laravel: Real-World Analytics

ClickHouse + Laravel: Real-World Analytics

────────────────────────────────────────────────────────

ClickHouse + Laravel: Real-World Analytics

Tip: Tracking Middleware

class TrackPageView
{
    public function handle(Request $request, Closure $next)
    {
        $response = $next($request);

        DB::connection('clickhouse')->table('page_views')->insert([
            'url' => $request->path(),
            'user_id' => auth()->id(),
            'ip' => $request->ip(),
            'user_agent' => $request->userAgent(),
            'created_at' => now(),
        ]);

        return $response;
    }
}

Track every page view in ClickHouse.

Gotcha: Don't Track in the Request Path

For high-traffic sites, queue the tracking:

TrackPageView::dispatch($request->path(), auth()->id());

Batch insert in a queue worker.

Tip: Dashboard Query

$stats = DB::connection('clickhouse')->select("
    SELECT toStartOfDay(created_at) as day, count() as views, uniq(user_id) as visitors
    FROM page_views
    WHERE created_at >= now() - INTERVAL 30 DAY
    GROUP BY day ORDER BY day
");

30-day traffic overview.

Gotcha: Separate Databases

  • PostgreSQL: users, posts, categories (transactional)
  • ClickHouse: page views, events, analytics (analytical)

Don't mix them.

Tip: Top Pages Query

$topPages = DB::connection('clickhouse')->select("
    SELECT url, count() as views
    FROM page_views
    WHERE created_at >= today() - 7
    GROUP BY url ORDER BY views DESC LIMIT 20
");

Gotcha: Data Retention

ClickHouse stores data forever by default. Set up partition dropping:

ALTER TABLE page_views DROP PARTITION '202301';

Tip: Unique Visitors

SELECT uniq(user_id) as unique_visitors FROM page_views WHERE created_at >= today();

uniq() is approximate but extremely fast.

Gotcha: Middleware Performance

Tracking middleware adds ~1-5ms per request. For high-traffic sites, use async/batch tracking.

Tip: Order of Columns in ORDER BY Matters Massively

ClickHouse's primary key is defined by ORDER BY. Put high-cardinality columns first for better data skipping. ORDER BY (timestamp, user_id) is very different from ORDER BY (user_id, timestamp) in query performance.

Tip: Use LowCardinality for Enum-Like Strings

Strings like status, country, browser benefit from LowCardinality(String) — it's stored as a dictionary internally, reducing storage 10x and speeding up scans.

Gotcha: Mutations Are Heavy

ALTER TABLE ... UPDATE and DELETE in ClickHouse create new parts instead of modifying in place. A single mutation on a large table can take hours and block merges. Design for append-only from day one.

Senior Insight

Building a real-world analytics system with ClickHouse and Laravel taught me that the ETL pipeline is more important than the database. I use Laravel's scheduled tasks to run nightly aggregation jobs, writing results to ClickHouse for dashboard queries. The raw event data stays in ClickHouse for 7 days (TTL-based deletion), while aggregated summaries are kept indefinitely. This balance gives me detailed debugging data for a week and long-term trends forever.

Source: ClickHouse Blog (https://clickhouse.com/blog), Altinity Blog (https://altinity.com/blog), Altinity Knowledge Base (https://kb.altinity.com/)

────────────────────────────────────────────────────────
<-- back to posts