Journal Entry, January 12th to January 19th 2024

January 19, 2024 · 3 min read

Laravel

I ran into an issue using the groupBy() method for collections where I wanted to "roll up" line items to list a single SKU and the quantity instead of one item per entry. This worked fine until "SKU sharing" was introduced where the same SKU could have different price points. In this case, it was a free opening night but the concept of "early bird pricing" was also introduced, hoping to share the same SKU. I'm unsure if this is against typical eCommerce rules but this is to synchronize orders into a centralized system, not a widespread concern.

A Deeper Look

The code looked something like:

return $registrations->groupBy('ticketLedgerCode')->map(function (Collection $registrations, string $key) {
    $quantity = $registrations->count();
    $registration = $registrations->first();

    return [
        'line_type' => 1,
        'item_number' => $registration?->ticketLedgerCode,
        'description' => $registration?->ticketName,
        'unit_price' => $this->getAmountFromStripe($registration?->price),
        'discount_code' => $registration?->promoCode,
        'discount_price' => $registration?->price,
        'discount_charge' => $registration?->charge,
        'quantity_ordered' => $quantity,
        'quantity_UOM' => "EA", // default
        'start_date' => date('Y-m-d'),
        'duration' => 12,
    ];
})->values()->toArray();

You may notice part of the problem immediately. The groupBy method converts an existing collection into a subcollection where $registration is the group of distinct ticketLedgerCode. Calling first() here is where the problem originates as this collection can have different prices that I didn't account for. After mapping over the result we use values() because the collection key is the ticketLedgerCode which we don't really need, we only care about the underlying result.

Correction

The groupBy method takes an array but it was harder to reason about for me and more difficult to traverse to the point I cared about. In the example above, $registrations would have the price as a key and a collection of the items that matched that price.

The result of the nesting looks something like:

{
  "123456789001": {
    "25900": [
      (registration)
    ]
  }
}

The approach that was easier to reason about was to nest another groupBy clause for the price field:

return $registrations->groupBy('ticketLedgerCode')->map(function (Collection $itemNumbers, string $key) {
    return $itemNumbers->groupBy('price')->map(function (Collection $prices, string $key) {
        $quantity = $prices->count();
        $registration = $prices->first();

        return [
            'line_type' => 1,
            'item_number' => $registration?->ticketLedgerCode,
            'description' => $registration?->ticketName,
            'unit_price' => $this->getAmountFromStripe($registration?->price),
            'discount_code' => $registration?->promoCode,
            'discount_price' => $registration?->price,
            'discount_charge' => $registration?->charge,
            'quantity_ordered' => $quantity,
            'quantity_UOM' => "EA",
            'start_date' => date('Y-m-d'),
            'duration' => 18,
        ];
    })->values();
})->values()->flatten(1)->toArray();

I had originally used first() instead of flatten(1) as I incorrectly assumed the outer array would always be the same but in the case of different SKUs the output is different than the JSON above:

{
  "123456789000": {
    "19900": [
      (registrations)
    ],
    "25900": [
      (registrations)
    ],
  },
  "123456789001": {
    "19900": [
      (registrations)
    ],
    "25900": [
      (registrations)
    ]
  }
}

The JSON notation is a little misleading using objects vs arrays but in Laravel and PHP in general this is represented as nested associative arrays.

Aside

Turns out there is an approach using groupBy(['ticketLedgerCode', 'price']) that I just worked out by creating this journal entry. Instead of $itemNumbers->groupBy('price')->map the internal section should just be $itemNumbers->map. If I were to nest a 3rd or 4th time, I would need to map down to those levels and flatten(1) would roll everything back together.

Conclusion

The real superstar here is flatten(1) as I incorrectly assumed it was the same as flatten(), but it isn't. The method defaults to infinity and will turn an array of records into an array of every single property, which is not what I wanted.

I'm not going to change the code to use an array as I don't mind the explicit groupBy call.

I had also tried using both Cody and ChatGPT to explain the issue and while Cody would generally do better during its beta, it wouldn't produce what I was looking for. ChatGPT 3.5, on the other hand, correctly intuited that flatten(1) was what I needed. AI code tools are great for explanations, and correct completions in Cody are like magic, but the hallucinations range from subtle to extremely problematic.