Expressive Eloquent Collections

Expressive Eloquent Collections

What is eloquent?

The goal

Readability

The result

  1. Readability.
  2. Extracting repeated logic.
  3. Default and improved sorting methods.
  4. Thinning models.
  5. Testability.
  6. Reducing database queries.
  7. Sharing a filtering API with eloquent scopes.
  8. Encapsulation.

Hey! I'm Tim

Developer; Musician; ๐Ÿถ lover;

Meet Taz

You: "awwwww"

My doggo Taz wearing star shapped glasses

Why I wanted to improve readability

The language of a model

Problem space specific.

if ($invoice->payment !== null) {
    //
}

if ($invoice->isPaid()) {
    //
}
if ($invoice->due_at->lt(now())) {
    //
}

if ($invoice->due_at->isPast()) {
    //
}

if ($invoice->isOverdue()) {
    //
}

The language of a collection

Set of generic items.

if ($collection->contains($item)) {
    //
}

if ($collection->isEmpty()) {
    //
}

Zero matches found

$items = collect([
    new GenericItem,
    new GenericItem,
]);

๐Ÿค”

Eloquent models speak the domain language.

Eloquent collections only contain eloquent models.

Why don't eloquent collections speak the domain language as well?

Eloquent collections

$allInvoicesArePaid = $invoices->every(function ($invoice) {
    return $invoice->isPaid();
});

if ($allInvoicesArePaid) {
    //
}

Higher order collection proxy

if ($invoices->every->isPaid()) {
    //
}

Extended collections

class InvoiceCollection extends Eloquent\Collection
{
    //
}

Wire it up

class Invoice extends Eloquent
{
    public function newCollection($models = [])
    {
        //
    }

    // ...
}

๐Ÿ˜”

$allInvoicesArePaidOrProcessing = $invoices->every(function ($invoice) {
    return $invoice->isPaid() || $invoice->paymentIsProcessing();
});

if ($allInvoicesArePaidOrProcessing) {
    //
}
class InvoiceCollection extends Eloquent\Collection
{
    public function areAllPaidOrProcessing()
    {
        return $this->every(function ($invoice) {
            return $invoice->isPaid() || $invoice->paymentIsProcessing();
        });
    }
}

๐Ÿ˜Œ

if ($invoices->areAllPaidOrProcessing()) {
    //
}

Readability โœ…

Just the beginning

Extracting repeated logic

Keepin' it DRY

Once, twice

$total = $invoices->reduce(function ($total, $invoice)
    return $total->add($invoice->cost);
}, new Money);

...thrice!

$total = $invoices->totalCost();

Default and improved sorting methods

๐Ÿฅบ

$users = User::query()->search($term, ['name'])->get();

$users = $users->sortByDesc(function ($user) use ($term) {
    // known set of users, don't @ me
    return similar_text(
        Str::lower($term),
        Str::lower($user->name)
    );
});

๐Ÿค—

$users = User::query()->search($term, ['name'])->get();

$users = $users->sortByRelevance($term, ['name']);

Thinning models

$users->each(function ($user) {
    $user->sendOverdueNotice();
});

$users->sendOverdueNotices();
// collection
$users->loadMissing('posts');

// model
$user->loadMissing('posts');

public function loadMissing($relations)
{
    $this->newCollection([$this])->loadMissing($relations);

    return $this;
}

Testability

public function test_it_calculates_totals()
{
    $invoices = factory(Invoice::class)->times(2)->create([
        'cost_in_cents' => 3333,
    ]);

    $this->assertSame(6666, $invoices->totalCost()->inCents());
}

Reducing database queries

$notifications->each->update(['read_at' => now()]);

Notification::query()
    ->whereKey($notifications->modelKeys())
    ->update(['read_at' => now()]);
NotificationCollection extends Eloquent\Collection
{
    public function query()
    {
        if ($this->isEmpty()) {
            return new NullQuery(static::class);
        }

        return Notification::query()->whereKey($this->modelKeys());
    }

    // ...
}
NotificationCollection extends Eloquent\Collection
{
    public function markAsRead()
    {
        $now = now();

        $this->query()->update(['read_at' => $now]);

        $this->each->setAttribute('read_at', $now);

        return $this;
    }

    // ...
}
$notifications->markAsRead();

// $notifications->each->update(['read_at' => now()]);

// Notification::query()
//    ->whereKey($notifications->modelKeys())
//    ->update(['read_at' => now()]);

๐Ÿคฅ

~~Reducing database queries~~

Cleaning up database queries

Sharing a filtering API with eloquent scopes

Slight tangent...

$users->where('name', '=', 'Jasmine');

User::query()->where('name', '=', 'Jasmine');
User::query()->whereName('Jasmine');
User::query()->whereNotNull('name');

User::query()
    ->whereName('Jasmine')
    ->whereNotNull('confirmed_email_at')
    ->active()
    // ...
class Notification extends Eloquent
{
    public function scopeWhereUnread($builder)
    {
        $builder->whereNull('read_at');
    }
}

$notifications = Notification::query()->whereUnread()->etc(...);
class NotificationCollection extends Eloquent\Collection
{
    public function whereUnread()
    {
        return $this->where('read_at', '=', null);
    }
}

$notifications->whereUnread()->etc(...);

๐Ÿค

#symmetry

Notification::whereUnread()->etc(...);

$notifications->whereUnread()->etc(...);

Encapsulation

๐Ÿคจ

~~Chicken or the egg~~

Model or the Collection

So, like, what even is Eloquent?

Model

Query Builder

+

Collection

Factories

Thanks!

https://tim.macdonald.au

twitter.com/timacdonald87

/