« back published by @mmartin_joo on October 20, 2022

Custom Eloquent Collections in Laravel

Laravel collections are great. They have a really functional, Javascript-y taste:

$sum = collect($numbers)
  ->filter(fn (int $x) => $x > 10)
  ->map(fn (int $x) => $x * 2)
  ->sum();

Another remarkable aspect of Laravel is custom collections. We can actually build our own collections to our own models.

Let’s say we’re working on an investment portfolio application where we have transactions and holdings. A user has a lot of transactions, for example they buy some Apple shares. From these transactions we can calculate their actual holdings. For example, if a person bought 10 shares of Apple and a week later he bought another 3 of them, now he has 13 shares of Apple. If he bought it for $100 each the first time but $110 the second time he has $1330 worth of shares.

If we want to do something like that we need calculate some things, for example:

  • How much shares does the user have? 10 + 3 = 13
  • What’s the value of his holding? (10 * $100) + (3 * $110) = $1330
  • What’s they weighted average price (or cost basis)? $102.3 (it’s near to $100 since they bought 10 shares at $100 but only 3 shares at $110)

This is how the weighted average price looks like:

$transactions = Transaction::where('ticker', 'AAPL')->get();

// For example they bought 10 shares but sold all of them
if ($transactions->sum('quantity') === 0.00) {
  return 0;
}

$sumOfProducts = $transactions
  ->sum(fn (Transaction $transaction) => 
    $transaction->quantity * $transaction->price_per_share
  );

$weightedPricePerShare = $sumOfProducts / $transactions->sum('quantity');

The question is: where do we put such code? There are a few options:

  • Model. It’s a fine solution, however I like to put as little logic into my models as possible. They just grow too big too quick. Another “problem” is that this piece of code would not use the $this variable so why should we put it in a class that represent one concrete Transaction? This function deals with a lot of transactions at once. It not a huge problem, for sure, but I also don’t feel like a model is the best place for this.
  • Action. It’s a good idea, in my opinion. It should be a class called CalculateWeightedAveragePriceAction which takes a collection of transactions and returns a float. My only problem is that the name looks annoying as hell. And imagine how many other actions like this we should have in this application. It would be pretty annoying to navigate, for sure. By the way, if you don’t know what is an action check out this and this article.
  • Service. It’s pretty similar to an action but instead of a lot of small classes, we would probably end up with a few big ones. Of course, it’s hard to say that because it’s just an imaginary scenario.

My other problem with actions or services is this:

$transactions = Transaction::where('ticker', 'AAPL')->get();

$averagePrice = $transactionService->calculateWeightedAveragePrice($transactions);

There’s nothing wrong with it, but it just doesn’t feel “first-class citizen” enough. The ideal solution would be something like that:

$averagePrice = Transaction::where('ticker', 'AAPL')
    ->get()
    ->weightedAveragePrice();

That’s what I meant by “first-class citizen.” Can you feel the difference? Of course you can! The point is: the whole “context” of this function is the collection itself. This is why it feels so natural on the collection itself.

Fortunately, in Laravel, we can write a custom collection for a model:

namespace App\Collections;

use App\Models\Transaction;
use Illuminate\Database\Eloquent\Collection;

class TransactionCollection extends Collection
{
  public function weightedPricePerShare(): float
  {
    if ($this->sum('quantity') === 0.00) {
      return 0;
    }

    $sumOfProducts = $this
      ->sum(fn (Transaction $transaction) => 
        $transaction->quantity * $transaction->price_per_share
      );

    return $sumOfProducts / $this->sum('quantity');
  }
}

So you can define a custom TransactionCollection that extends Laravel's Collection class. As you can see, we can use the $this variable to access the standard collection methods we all love.

The last step is to instruct Laravel to actually use this class whenever we're creating a new collection from Transaction models:

namespace App\Models;

use App\Collections\Transaction\TransactionCollection;
use Illuminate\Database\Eloquent\Factories\HasFactory;

class Transaction extends Model
{
  use HasFactory;

  public function newCollection(array $models = []): TransactionCollection
  {
    return new TransactionCollection($models);
  }
}

Now we can do this:

$holding = new Holding();

$transactions = Transaction::all();

$holding->price_per_share = $transactions->weightedPricePerShare();

And here's the important thing. A transaction doesn't have a weighted price per share. But a collection of transactions do have. The example above expresses this perfectly. So by using custom collections, we can write more expressive, domain-oriented code.

Since we want to sum everything when it comes to transactions, let's add some more methods to the collection class:

namespace App\Collections\Transaction;

use App\Models\Transaction;
use Illuminate\Database\Eloquent\Collection;

class TransactionCollection extends Collection
{
  public function sumQuantity(): float
  {
    return $this->sum('quantity');
  }

  public function sumTotalPrice(): float
  {
    return $this->sum('total_price');
  }

  public function weightedPricePerShare(): float
  {
    if ($this->sumQuantity() === 0.00) {
      return 0;
    }

    $sumOfProducts = $this
      ->sum(fn (Transaction $transaction) => 
        $transaction->quantity * $transaction->price_per_share
      );

    return $sumOfProducts / $this->sumQuantity();
  }
}

And now imagine an action that create a new holding from transactions:

class CreateHoldingFromTransactions
{
  public function execute(
    int $stockId,
    TransactionCollection $transactions, 
  ): Holding {
    return Holding::create([
      'stock_id' => $stockId,
      'average_cost' => $transactions->weightedPricePerShare(),
      'quantity' => $transactions->sumQuantity(),
      'invested_capital' => $transactions->sumTotalPrice(),
    ]);
  }
}

It's very high-level, has type-hints everywhere, and it helps you to bring the code closer to the domain language, which is very important to me. You can apply this technique to a significant number of things.

When to use custom Eloquent collections?

  • When the context of your function is a lot of model it’s a good indicator of a custom collection.
  • 1-to-many relationships and parent-child relations are also a good place to start.
  • Does your static model function loops through a collection of models? Maybe refactor it to a custom collection.
  • Repeating the same collection method or method chain. For example you have 9 instances of $transactions→sum(’quantity’)
  • You have a function that doesn’t fit any existing class. For example, the calculateWeightedAveragePrice. It just feels weird inside any existing class.