Chapter Query Scopes mengajari kita membungkus kondisi query ke method scopePopular di model. Semakin banyak scope tumbuh, model berubah menjadi kumpulan query berjalan — dan sebagian scope seperti published(), recent(), byUser() sebenarnya adalah bahasa domain aplikasi, bukan detail model. Custom Eloquent builder memindahkan seluruh kosakata query itu ke satu kelas PostBuilder tersendiri, sehingga Post::popular()->recent()->get() menjadi API resmi aplikasi. Kita juga akan melihat bahwa builder kustom otomatis dipakai di semua pintu — query langsung, relasi, sampai eager loading .

Class Builder Kustom #

Builder kustom adalah subclass dari Illuminate\Database\Eloquent\Builder:

php
<?php

namespace App\Builders;

use App\Models\User;
use Illuminate\Database\Eloquent\Builder;

/**
 * @template TModel of \App\Models\Post
 *
 * @extends Builder<TModel>
 */
class PostBuilder extends Builder
{
    public function popular(int $minViews = 100): static
    {
        return $this->where('views', '>=', $minViews)->orderByDesc('views');
    }

    public function recent(): static
    {
        return $this->whereNotNull('published_at')->orderByDesc('published_at');
    }

    public function byUser(User|int $user): static
    {
        return $this->where('user_id', $user instanceof User ? $user->id : $user);
    }
}

Dua hal membuat kelas ini enak dipakai. Setiap method mengembalikan $this bertipe static, sehingga rantai pemanggilan tidak pernah kehilangan tipe PostBuilder — autocomplete editor tetap menawarkan popular() setelah recent(). Docblock @extends Builder<TModel> menjaga generic type sehingga ->get() tetap dikenal mengembalikan koleksi Post, bukan Model serba bisa.

Menghubungkan Builder ke Model #

Model menunjuk builder-nya lewat method newEloquentBuilder():

php
<?php

// app/Models/Post.php — bagian inti
public function newEloquentBuilder($query): PostBuilder
{
    return new PostBuilder($query);
}

Laravel 13 menambahkan cara deklaratif: attribute #[UseEloquentBuilder] di atas kelas model — keduanya setara:

php
<?php

// app/Models/Post.php — alternatif di Laravel 13
#[UseEloquentBuilder(PostBuilder::class)]
class Post extends Model
{
    // ...
}

Attribute ini butuh use Illuminate\Database\Eloquent\Attributes\UseEloquentBuilder; di bagian import. Model dengan builder kustom tetap kompatibel penuh dengan seluruh fitur Eloquent — yang berubah hanya kelas di balik Post::query().

Bahasa Query Domain #

Dengan koneksi di atas, pemanggilan query berubah menjadi kalimat domain. Mengingat data kita — Budi punya tiga post dan Siti dua post di tabel posts:

php
<?php

foreach (Post::popular()->recent()->get() as $post) {
    echo "{$post->title} ({$post->views} views)".PHP_EOL;
}

Output yang Dihasilkan:

text
Belajar Laravel 13 (320 views)
Rahasia Eloquent (145 views)

Filter per penulis menerima objek User maupun id:

php
<?php

foreach (Post::byUser($budi)->get() as $post) {
    echo $post->title.PHP_EOL;
}

Output yang Dihasilkan:

text
Belajar Laravel 13
Rahasia Eloquent
Migration Tanpa Drama

Builder kustom juga otomatis mengalir ke relasi, karena HasMany membangun query lewat builder model yang sama:

php
<?php

echo 'builder query: '.get_class($siti->posts()->getQuery()).PHP_EOL;
echo 'posts milik Siti yang popular: '.$siti->posts()->popular()->count().PHP_EOL;

Output yang Dihasilkan:

text
builder query: App\Builders\PostBuilder
posts milik Siti yang popular: 0

Untuk kondisi opsional — misalnya filter hanya aktif bila request membawa parameter — builder mewarisi when() dan unless() dari trait Conditionable, sama seperti query biasa:

php
<?php

$minViews = 100;

$posts = Post::query()
    ->when($minViews >= 100, fn ($q) => $q->popular($minViews))
    ->unless($minViews >= 500, fn ($q) => $q->where('views', '<=', 400))
    ->orderByDesc('views')
    ->get();

Method when() menjalankan closure pertama bila kondisi benar; unless() sebaliknya, menjalankan closure saat kondisi salah — keduanya menghilangkan if bertingkat di controller.

Builder atau Scope #

Keduanya memecahkan masalah yang sama dengan biaya berbeda:

AspekQuery scopeCustom builder
Tempat kodeMethod di model (scopePopular)Class terpisah (PostBuilder)
Penamaan pemanggilanPost::popular() (prefix scope disingkirkan)Post::popular() — sama
Ukuran wajar3–10 method10+ method atau satu domain query
Type di editorHilang setelah scope pertamaTetap PostBuilder di sepanjang rantai
Dipakai lintas modelTidak, per modelYa, bila builder memang dimaksudkan lintas model

Praktik yang sehat: mulai dengan scope untuk satu-dua kondisi, lalu angkat seluruhnya menjadi builder ketika daftar scope melewati sepuluh atau method-nya butuh tipe kembalian yang kuat.

Macro untuk Satu Method Saja #

Kadang kita hanya butuh satu method ekstra pada semua query — misalnya pencarian LIKE untuk kolom apa pun. Alih-alih membuat builder kustom untuk satu method, daftarkan macro pada Illuminate\Database\Eloquent\Builder di method boot() milik AppServiceProvider:

php
<?php

Builder::macro('whereLike', function (string $column, string $keyword): Builder {
    /** @var Builder $this */
    return $this->where($column, 'LIKE', "%{$keyword}%");
});

Macro langsung tersedia di semua model, tanpa subclass:

php
<?php

echo Post::whereLike('title', 'Laravel')->toRawSql().PHP_EOL;

Output yang Dihasilkan:

text
select * from "posts" where "title" LIKE '%Laravel%'

Mekanisme macro ini akan kita bedah tuntas di chapter Macros .

Menguji Builder dalam Isolasi #

Test builder tidak membutuhkan baris data sama sekali — method toRawSql() merakit string SQL final tanpa menyentuh database. Gabungkan dengan test berdata untuk perilaku nyata:

Output yang Dihasilkan:

text
  PASS  Tests\Feature\PostBuilderTest
  ✓  sql builder bisa diuji tanpa database                              0.13s
  ✓  popular menyaring dan mengurutkan postingan                        0.02s
  ✓  by user menerima model atau id                                     0.01s
  ✓  where like macro dari provider                                     0.01s

  Tests:    4 passed (6 assertions)
  Duration: 0.25s

Test pertama cukup mengandalkan assertStringContainsString('"views" >= 150', Post::popular(150)->recent()->toRawSql()) — assertion pada SQL mentah, tanpa migrasi dan tanpa factory. Test berikutnya menyemai data lewat factory lalu memastikan hasil get() dan pluck() sesuai. Kombinasi ini membuat kegagalan mudah dilacak: test SQL menangkap kesalahan kondisi, test data menangkap kesalahan logika.

PostBuilder kita kini menjadi kamus resmi untuk semua query post. Macro whereLike di atas memberi petunjuk mekanisme yang lebih umum — kemampuan menambah method ke class framework mana pun pada saat runtime. Chapter berikutnya membuka kap mesinnya.