<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
LARAVEL · JUNIOR ЧАСТО ПИТАЮТЬ

Що таке scopes, accessors і casts в Eloquent і де вони доречні?

Scope - це іменований шматок запиту: локальний ви викликаєте самі (`Post::published()`), глобальний Eloquent додає в кожен запит моделі, як `SoftDeletes`. Accessor через `Attribute::get()` і cast працюють уже після вибірки, у PHP: каст перетворює значення колонки в обидва боки, аксесор рахує похідний атрибут, якого в таблиці немає, тому фільтрувати й сортувати за ним не можна, а `$appends` додає його в `toArray()` і `toJson()`.

Що робить атрибут `#[Scope]` над методом моделі і чим такий метод відрізняється від звичайного публічного методу?
Аксесор `full_name` віддає правильне значення, а `where('full_name', 'Іван Петренко')` падає з `Unknown column` - чому?
Додали `$appends = ['orders_total']`, і список із 50 клієнтів почав робити 51 запит. Що сталось?
`$post->published_at` повертає `CarbonImmutable`, хоча в MySQL це рядок. Хто і коли його перетворює?
Eloquent scopes accessors casts appends серіалізація

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 - це фрагмент білдера з явним викликом, а глобальний застосовується до кожного запиту моделі автоматично; що `SoftDeletes` - це саме глобальний scope, а не магія фреймворку.
Що метод зі старим префіксом `scopePublished()` і метод із атрибутом `#[Scope]` (Laravel 12+) роблять одне й те саме: перший аргумент - `Builder`, виклик іде через `__call` і `callNamedScope`, а повертати білдер не обовʼязково.
Що аксесор і каст виконуються в PHP після того, як рядки вже прийшли з бази: у SQL цих атрибутів не існує, тому `where` і `orderBy` за ними неможливі, і сортування «за повним імʼям» роблять по колонках або по згенерованій колонці.
Що `casts()` (метод, з Laravel 11) працює в обидва боки: enum, `immutable_datetime`, `AsArrayObject`, `hashed`, власний клас з `CastsAttributes` - і що `getRawOriginal()` віддає значення до касту.
Що `$appends` тягне аксесор у кожну серіалізацію, тож важкий аксесор перетворює список на N+1, і що поля «під один ендпоїнт» місце в ресурсі, а не в моделі.
Викликати scope як метод моделі: `$post->scopePublished()` замість `Post::published()` або `$user->posts()->published()`.
Фільтрувати за аксесором: `User::where('full_name', $q)` або `orderBy('reading_time')` - база не знає про ці атрибути, буде `Unknown column`.
Покладатись на глобальний scope мультитенантності й піти повз нього: `DB::table('orders')->sum('total')` у звіті поверне дані всіх тенантів, бо scope живе на рівні Eloquent-білдера.
Тягнути в аксесор запит до бази або виклик сервісу (`$this->orders()->sum('total')`, `Cache::get()`): код відпрацює для кожного рядка списку й для кожної серіалізації.
Вважати, що результат аксесора кешується: обʼєкти кешуються, а скалярне значення перераховується на кожне звертання, поки не додано `->shouldCache()`.
Додати `$appends` заради одного ендпоїнта й отримати це поле скрізь - у логах, у вебхуках, у відповідях адмінки.
ПОРАДА

Проведіть межу по тому, де виконується код: scope - це SQL (його можна фільтрувати, сортувати, індексувати), accessor і cast - це PHP уже над готовим рядком. Звідси й відповідь на «де доречні»: усе, що звужує вибірку, має бути scope; усе, що оформлює одне значення, - cast; похідне поле для показу - accessor, і бажано без `$appends`, а через ресурс.

оновлено 3 жовтня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

`where()` будує SQL, а аксесор виконується вже над завантаженим рядком, тому колонки `reading_time` для бази не існує - звідси `Unknown column`. `#[Scope]` позначає саме локальний scope: метод шукається через `isScopeMethodWithAttribute()` під час виклику `Post::published()`, а глобальні реєструють через `addGlobalScope()` або `#[ScopedBy]`. Касти й аксесори живуть у моделі, тож `DB::table()` віддасть сирий `stdClass` без жодних перетворень - і глобальні scope його теж не фільтрують.