leovie/php-grouper
leovie/php-grouper is a lightweight PHP utility for organizing arrays and collections into groups using custom rules. It offers a simple API for defining grouping logic and retrieving grouped results, helping keep data-processing code clean and readable.
1. Read the source before you install anything. The whole package is a handful of PHP files. Open src/ and confirm three things you cannot get from the README:
src/ rather than trusting the docs);composer.json php constraint — the last tag is v2.0.1 (2022-05-25), so it was written against a PHP 8.0/8.1-era runtime;Ten minutes here saves you an afternoon of production debugging later.
2. Gate on compatibility, then pin.
composer why-not leovie/php-grouper 2.0.1 # against your actual app
composer require leovie/php-grouper:2.0.1 # exact pin, no caret
Do not use ^2.0. There has been no release in three years, no dependents, and a single maintainer. An exact pin means a future composer update on your wider stack fails loudly in CI instead of silently pulling an unknown commit.
3. Wrap it in one adapter before your first call site. This is the single most important step and costs you an hour:
namespace App\Support\Grouping;
use Illuminate\Support\Collection;
final class Grouper
{
/**
* @param Collection<int, mixed> $items
* @return array<string, Collection<int, mixed>>
*/
public static function by(Collection $items, callable $keyFn): array
{
// Implementation detail. Today: the package. Tomorrow: whatever.
return $items->groupBy($keyFn)->all();
}
}
Whether the body calls the package or Collection::groupBy() is now a one-file decision. Given the package's health signals, that adapter is the entire value of adopting it — it lets you spike, compare, and back out without touching application code.
4. First use case: something with zero blast radius. Not billing, not anything on the request path. Good first targets:
5. Spike the four edge cases immediately and write down observed behaviour:
// null values in the criteria
// a missing/undefined property on one object in the collection
// mixed types (int 1 vs string "1" vs 1.0)
// multi-criteria / nested grouping
Whatever the package actually does here is your contract. Encode it as tests in your suite, so a future replacement is validated against real expectations rather than the package's.
Every call site goes through App\Support\Grouping\Grouper. Never scatter the package's FQCN across services. A leaf dependency with no support contract should appear in exactly one or two files in your codebase.
groupBy in LaravelIf all you need is "bucket these models by status", Laravel already does it and you should not add a dependency:
$byStatus = Order::all()->groupBy('status'); // keyed collections
Reach for the package only when it earns its place: composite keys, chainable multi-level grouping, or reuse of grouping logic outside Eloquent collections (plain arrays of DTOs, stdClass rows from a query builder, decoded JSON).
The common genuine need is "break down by region and month" without each team inventing its own key-composition convention. Standardise the separator and the ordering in your adapter:
$grouped = Grouper::by($orders, fn (Order $o) => sprintf(
'%s|%s',
$o->region,
$o->created_at->format('Y-m'),
));
Do the composition in your code, not the package's. Then the semantics (separator, timezone, date format, whether null becomes "null" or an empty segment) live where you can test and change them.
If the chainable API is real, compose it. Otherwise nest explicitly — it's clearer either way:
$byRegion = Grouper::by($orders, fn (Order $o) => $o->region);
$nested = collect($byRegion)->map(
fn (Collection $orders) => Grouper::by($orders, fn (Order $o) => $o->status)
)->all();
Group after the query, never as a substitute for with():
$tickets = Ticket::with('assignee')->where('open', true)->get();
$byAssignee = Grouper::by($tickets, fn (Ticket $t) => $t->assignee?->name ?? 'Unassigned');
If you group by a relation property, with() the relation or you'll lazy-load on every item inside the callback.
Grouping is inherently in-memory; don't get() 500k rows to group them. Either aggregate in SQL (GROUP BY, which is almost always the right answer for reporting) or, if you genuinely need PHP-side grouping, use chunkById and merge:
$acc = [];
Order::query()->chunkById(1000, function ($orders) use (&$acc) {
foreach (Grouper::by($orders, fn (Order $o) => $o->tenant_id) as $key => $group) {
$acc[$key] = array_merge($acc[$key] ?? [], $group);
}
});
Write your assertions against the adapter, not the package:
it('groups by a composed key and preserves insertion order', function () {
$items = collect([/* ... */]);
expect(Grouper::by($items, fn ($i) => "{$i->region}|{$i->month}"))
->toHaveKey('EU|2024-01')
->and(array_keys(Grouper::by($items, fn ($i) => $i->region)))
->toBe(['EU', 'US']); // ordering is part of the contract
});
This is unmaintained source code, not a supported library. Last release 2022-05-25, 0 stars, 0 dependents, one maintainer. Treat every bug you hit as an internal bug. Keep the BSD-3-Clause copyright notice intact if you vendor the source into app/Support.
PHP 8.3/8.4 will eventually bite. A 2022 codebase may use implicit nullable parameters or other constructs deprecated since. If your team tracks PHP majors, add the package to a composer outdated watch and re-check the adapter at each PHP upgrade. This is the highest-probability failure mode and it's entirely manageable because you wrapped it.
Key coercion is the classic silent bug. PHP turns numeric-string array keys into ints. If your grouping key is an ID or a "2024-01"-style string, "1" and 1 collapse into one bucket. If that matters, prefix keys ("id:1", "ym:2024-01") or compose a delimited string yourself.
Null and missing properties. Undocumented here and likely divergent from what you'd assume. Decide explicitly: does the criteria callback throw on a null property, or bucket it under ""? If the package calls a property name rather than a closure, test what happens when one object in the collection lacks it. In your adapter, normalise to a sentinel:
fn (Ticket $t) => $t->assignee?->name ?? 'Unassigned'
Never rely on the package's default null handling for anything user-facing.
Object identity vs. value equality. If two distinct objects with equal property values land in the same group, that's key-value grouping (usually what you want). But if the package groups by the object itself somewhere, you'll get one bucket per instance. Verify before shipping.
Cast and enum surprises. Eloquent casts mean the "raw" property isn't what your callback sees. $order->created_at is a Carbon, not a string — sprintf('%s', $order->created_at) won't give you the date format you expect. Enums need ->value. Cast explicitly inside the callback.
Output ordering. Insertion order preservation is claimed, but group key ordering is not specified. If a report renders groups in iteration order, that's a reshuffling support ticket waiting to happen — ksort() your output at the presentation layer.
Debugging. dd() on a nested grouped structure is painful. Dump the shape first:
$grouped = Grouper::by($items, $fn);
dump(array_map('count', $grouped)); // key => bucket size, instantly readable
And when behaviour surprises you, add a failing test before changing anything — you're defining the contract, not discovering it.
Memory. Grouping copies arrays of object references, so it's cheap per item, but the outer structure grows with cardinality. High-cardinality grouping (by user ID across millions of rows) is where a tiny general-purpose utility can surprise you. Benchmark at your real cardinality, or push the GROUP BY into SQL.
Extension point = the adapter. If you outgrow the package, delete the adapter body and implement ~50 lines over Collection::groupBy, or vendor the upstream source with its license header. Either way it's one file. That reversibility is the only strong argument for trying the package at all — the cost of being wrong is near zero, and the cost of not standardising your grouping semantics across teams is a recurring class of "these two dashboards disagree" bugs.
Verdict for day-to-day use: treat it as a reference design and a one-week spike, not a foundation. If your team's grouping needs are single-key, Collection::groupBy wins outright. If they're genuinely multi-key and multi-level, adopt the pattern — through the adapter — and keep the exit door open.
How can I help you explore Laravel packages today?