romanzipp/laravel-projectable-aggregates
Laravel package for projecting aggregate values such as counts, sums, and averages from related Eloquent models onto a parent model. Define projections declaratively and keep denormalized columns in sync automatically, avoiding repeated aggregate queries and simplifying reporting.
Install
composer require romanzipp/laravel-projectable-aggregates
php artisan vendor:publish --tag="projectable-aggregates" # config + migration stubs
php artisan migrate
Read the source before you write a projector. The package is small (13 stars, MIT, zero dependents), which means the src/ directory is the documentation. Ten minutes in vendor/romanzipp/laravel-projectable-aggregates/src will save you an afternoon of guessing:
Projector base class — this is the entire API surface. It tells you the exact signature each aggregate factory takes ($this->count(...), $this->sum(...), $this->avg(...)), and whether the second argument is a closure constraint, a relation name, or a query builder.Count, Sum, Avg, Min, Max, or similar) — what is supported out of the box, and what you'd extend to add a custom aggregate.Projectable trait — how a model exposes projected values. Is the projected value a plain attribute, an accessor, or a relation? This determines whether you can orderBy() and index it directly.php artisan list | grep -i project — the package normally ships a bulk (re)build command. You will run this constantly; find it now.First use case — the canonical one. A Post that needs comments_count, score_sum, and last_comment_at denormalized onto its own row, so you can orderBy('comments_count'), paginate, filter, and index without withCount() running a subquery on every request.
namespace App\Projectors;
use App\Models\Post;
use RomanZipp\ProjectableAggregates\Projector;
class PostProjector extends Projector
{
public function model(): string
{
return Post::class;
}
public function aggregates(): array
{
return [
$this->count('comments', as: 'comments_count'),
$this->sum('comments', 'score', as: 'comments_score_sum'),
$this->max('comments', 'created_at', as: 'last_comment_at'),
];
}
}
Wire the projector to the source model (Comment) via whatever trait or registration mechanism the package uses, add the three columns to posts in a migration, then run the rebuild command once to backfill. From that point on, every Comment create/update/delete should move posts.comments_count. Verify this before you write a single read path.
The first thing to verify empirically — not from the README — is: does deleting a comment decrement the count, and does a soft-deleted comment stop counting? Insert three comments, delete one, dd(Post::first()->comments_count). That one test tells you more about the package than any amount of documentation.
Projector-per-aggregate-set. Build one projector class per parent model that owns the full set of columns for that model. Don't scatter comments_count across one projector and comments_score_sum across another — a single projector means a single recompute pass per source event, which halves write amplification compared to two.
Start in shadow mode. The day-one pattern for a production rollout:
withCount() queries in place, and in a queued job (or a middleware on staging), compute both and log mismatches:$projected = $post->comments_count;
$actual = $post->comments()->count();
if ($projected !== $actual) {
Log::warning('Aggregate drift', compact('post', 'projected', 'actual'));
}
Give it a week of real traffic. If drift is zero, cut reads over. If drift is non-zero, you've found the write path the package doesn't cover — usually a bulk import, a raw SQL fix, or a queue retry — before it showed wrong numbers to a customer.
Backfill in chunks, off-peak. The rebuild command is fine for a thousand rows; for a million, wrap it in a throttled job:
Post::query()->chunkById(500, function ($posts) {
app(PostProjector::class)->rebuild($posts); // whatever the signature is
});
Chunking by ID matters: chunk() on an indexed id avoids offset scans, and it keeps you from holding a long transaction that locks the parent table.
Reads are the easy part. Once the columns exist and are correct, your day-to-day code looks like normal Eloquent — the whole point:
// Before: expensive subquery on every request
Post::withCount('comments')->orderByDesc('comments_count')->paginate();
// After: index the column, query it like any other scalar
Post::orderByDesc('comments_count')->paginate();
Add an index in the same migration that adds the column. There is no point denormalizing an aggregate you can't efficiently sort by.
Register the projector in one place. Whatever the package's registration mechanism is (service provider, config array, model trait), centralize it. When you add the fourth projector, you want one obvious list to edit, not four places to grep.
Feature-flag the read cutover per endpoint. When a dashboard shows a wrong count, you want to flip one flag and fall back to withCount(), not revert a deploy. Keep the old query method on the model as comments_count_computed() (or similar) for exactly this purpose.
Custom aggregates — if you need median, distinct-count, or a filtered sum ("open tickets only"), check whether the projector supports a constraint closure before writing anything:
$this->count('tickets', as: 'open_tickets_count', fn ($q) => $q->where('status', 'open'));
If constraints aren't supported, extend the base aggregate class — the package is dependency-free and trait-shaped, so this is a small change you can vendor into app/Support if the maintainer is unresponsive.
Never write these columns directly. They are caches. Any code that sets comments_count = 42 by hand is a bug waiting to be overwritten by the next event. Enforce this with a code review note in the model docblock and, ideally, a DB comment on the column.
Bulk writes silently desync. Comment::insert([...]) and Comment::upsert([...]) do not fire model events. Neither does DB::table('comments')->insert(...), nor a raw SQL fix in a console, nor a cascade delete defined at foreign-key level. Every one of these paths leaves your projected columns stale until something else touches the parent. If your app does bulk imports, either route them through model events (->each->save(), slower but safe) or schedule a reconciliation job. This is the single most common way these systems break in production.
Soft deletes need explicit verification. The default deleted() event fires on soft delete, but whether the package maps that to a decrement is a detail you must read in the source — and whether it re-increments on restored() is another. Write a test for both. If restore isn't handled, your projected count will only ever go down.
Concurrency on hot parents. If one Post gets thousands of comment-writes an hour, every write is an UPDATE posts SET comments_count = .... Read the source and confirm it uses atomic increment()/decrement() rather than read-modify-write ($post->comments_count + 1; $post->save()). The latter loses updates under concurrent writes and is the classic bug in hand-rolled versions of this pattern. If it's read-modify-write, wrap in a transaction with lockForUpdate() or fork the increment.
Recompute happens inside or outside your transaction — find out. If the source write succeeds and the aggregate recompute throws, is the source row rolled back? If the aggregate update is a separate transaction, you can have a committed comment with an unchanged count. If you care, wrap the whole thing in DB::transaction() at your call site — but test that you don't deadlock against the package's own locking.
No reconciliation command? Write one. Regardless of what ships, a nightly job that recomputes a sample (or everything) from source and reports drift is non-negotiable for anything user-facing. It's your safety net when a bulk import goes wrong at 2am.
// app/Console/Commands/ReconcileAggregates.php
Post::query()->chunkById(500, function ($posts) {
foreach ($posts as $post) {
$actual = $post->comments()->count();
if ($actual !== $post->comments_count) {
Log::warning('Reconciling', ['post' => $post->id]);
$post->update(['comments_count' => $actual]);
}
}
});
Debugging drift starts in the event log, not the code. When a number is wrong, ask "what wrote to the child table in the last hour?" Check updated_at on the source rows, grep for insert(/update( on that table, and look at your queue for failed jobs. The projector is almost never the bug; the write path that bypassed it is.
Version pinning is not optional. Single maintainer, 13 stars, 0 dependents. Pin the exact version in composer.lock, and before any Laravel major upgrade, run your own test suite against the new framework — the package may lag for weeks or months. Have a fork plan: the surface is small enough (a trait, a base class, a few aggregate types) to reimplement in a sprint if you have to.
Don't put this in a billing or inventory path. Stale counts on a leaderboard are a cosmetic bug; a stale sum on an invoice is a correctness incident. Scope it to dashboards, badges, admin lists, and rankings.
Indexes. Add an index on every projected column you sort or filter by, in the same migration. On Postgres and MySQL, sorting a million rows by an unindexed integer column is exactly the problem you were trying to solve.
The trait may not be retroactive. If projected columns are exposed via an accessor rather than a real column, orderBy() on them will fail or full-scan. Confirm in the source whether the trait writes to real attributes or computes on read — if the latter, you've gained nothing and lost clarity.
When you outgrow it. The pattern moves cost from reads to writes. If a parent row receives enough child mutations that the aggregate UPDATE becomes the bottleneck, you've hit the ceiling. At that point the answer is a queued/batched increment, a Redis counter flushed periodically, or a materialized view — not a bigger projector.
How can I help you explore Laravel packages today?