Scope дає імʼя шматку запиту, щоб потім його перевикористати. Механіка проста: Post::published() не знаходить такого методу в моделі, потрапляє в __callStatic, звідти в білдер, і той через callNamedScope() викликає ваш метод, підставляючи Builder першим аргументом. Історично метод мусив називатися scopePublished(), з Laravel 12 достатньо позначити його атрибутом #[Scope] і назвати published(); обидва варіанти працюють, головне не робити метод private. Повертати білдер не обовʼязково, callScope() підставить його сам, тому void у сигнатурі нормальний. Корисна деталь, яку рідко знають: білдер запамʼятовує кількість where до виклику scope і, якщо їх побільшало, загортає додані умови в окремі дужки. Завдяки цьому orWhere всередині scope не «протікає» назовні й не ламає зовнішній фільтр.
Глобальний scope відрізняється лише моментом застосування: його додають у кожен запит моделі без виклику. Це клас з методом apply(Builder $builder, Model $model), зареєстрований у booted() через addGlobalScope() або клас-атрибутом #[ScopedBy]. Найвідоміший приклад ви вже використовували: SoftDeletes - це трейт плюс глобальний scope, який дописує where deleted_at is null, а withTrashed() цей scope знімає. Платите за таку зручність неявністю. Запит, написаний повз Eloquent (DB::table('orders')), глобального scope не побачить, і в мультитенантному проєкті це різниця між звітом по одному клієнту й звітом по всіх одразу. Тому глобальні scope доречні там, де правило справді стосується всіх читань моделі, а не там, де просто набридло писати один і той самий where.
Касти й аксесори працюють з іншого боку: рядки вже прийшли з бази, і питання в тому, як вони виглядають у PHP. Каст описує колонку в обидва боки: 'status' => PostStatus::class перетворює рядок у backed enum на читанні й назад на записі, 'published_at' => 'immutable_datetime' дає CarbonImmutable, 'password' => 'hashed' хешує на присвоєнні. З Laravel 11 їх оголошують методом casts(), а не властивістю $casts, і це зручніше: усередині можна викликати AsCollection::of(LineItem::class) чи свій клас з CastsAttributes. Якщо потрібне значення до перетворення, є getRawOriginal('meta'); звичайний getOriginal() касти застосовує. Аксесор через Attribute::get() додає атрибут, якого в таблиці немає взагалі. Тут ховається найчастіше junior-питання співбесіди: $user->full_name працює, а where('full_name', ...) падає, бо перше виконується в PHP над завантаженим обʼєктом, друге в SQL, і між ними немає містка. Сортування й фільтрація за похідним значенням роблять або по вихідних колонках, або згенерованою колонкою в базі, або підзапитом у scope. І про кешування: обʼєктні значення Eloquent тримає в attributeCastCache, а скаляр перераховує щоразу, поки ви не додали ->shouldCache().
$appends зʼєднує аксесор із серіалізацією. toArray() бере атрибути після кастів, додає перелічені в $appends аксесори, прибирає $hidden і дописує завантажені звʼязки; toJson() іде тим самим шляхом. Звідси дві типові біди. Перша: поле, потрібне одному ендпоїнту, тепер їде всюди - у вебхуках, у логах, у відповіді адмінки. Друга серйозніша: аксесор orders_total, що робить $this->orders()->sum('total'), перетворює список із 50 клієнтів на 51 запит, і виглядає це як «раптово повільна сторінка» без жодної зміни в контролері. Тому аксесор в $appends має бути чистою функцією від уже наявних атрибутів, а все, що потребує додаткових даних, або приходить через withSum()/withCount(), або взагалі живе в класі ресурсу, де видно, для якої саме відповіді воно рахується.
Остання межа стосується бізнес-логіки. Аксесор спокусливо перетворити на метод «порахувати знижку» або «визначити, чи можна оплатити»: доступ короткий, працює звідусіль. Проблема в тому, що ви не контролюєте, коли він виконається. Аксесор спрацює в циклі по колекції, під час серіалізації в чергу, у Blade-шаблоні, у дебаг-виводі, і кожного разу потягне за собою свої залежності. Його не можна викликати з параметрами, не можна замокати, важко протестувати окремо, і він не існує в SQL, тому звіт по тій самій логіці доведеться писати вдруге вручну. Робоче правило: модель описує форму даних, тобто касти, похідні атрибути для показу, іменовані фрагменти запитів; рішення й зміни стану живуть в окремих класах, які модель приймають аргументом. Якщо логіку хочеться вивести в значення, а не в дію, беріть не аксесор, а власний каст, що повертає value object: тоді Money чи Email з усіма правилами всередині стає типом колонки, а модель лишається тонкою.
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
final class Post extends Model
{
/** Аксесор у JSON: рахується при кожній серіалізації, тож тримаємо його дешевим. */
protected $appends = ['reading_time'];
/** З Laravel 11 касти оголошують методом: видно типи й можна викликати статику. */
protected function casts(): array
{
return [
'status' => PostStatus::class, // backed enum в обидва боки
'published_at' => 'immutable_datetime',
'meta' => AsArrayObject::class, // json-колонка, зміни всередині теж зберігаються
];
}
/** Локальний scope: з Laravel 12 замість префікса scopePublished(). */
#[Scope]
protected function published(Builder $query): void
{
$query->where('status', PostStatus::Published)
->where('published_at', '<=', now());
}
/** Похідне значення з уже завантажених атрибутів. Жодних запитів усередині. */
protected function readingTime(): Attribute
{
return Attribute::get(function (): int {
// str_word_count не рахує кирилицю, тому ділимо за пробілами
$words = count(preg_split('/\s+/u', trim((string) $this->body)) ?: []);
return max(1, (int) ceil($words / 200));
})->shouldCache(); // без цього значення перераховується на кожне звертання
}
}
// Фільтр живе в SQL, аксесор - у PHP: orderBy('reading_time') тут неможливий.
$posts = Post::published()->orderByDesc('published_at')->paginate(20);
Проведіть межу по тому, де виконується код: scope - це SQL (його можна фільтрувати, сортувати, індексувати), accessor і cast - це PHP уже над готовим рядком. Звідси й відповідь на «де доречні»: усе, що звужує вибірку, має бути scope; усе, що оформлює одне значення, - cast; похідне поле для показу - accessor, і бажано без `$appends`, а через ресурс.