Przypominamy sobie różnicę między global query scopes oraz local query scopes plus uczymy się to pierwsze pisać „po staremu”. Do dzieła.
Zasięg globalny tworzyliśmy komendą:
php artisan make:scope HiddenScope
Na razie nie wiemy jeszcze co to znaczy, ale powiedzmy sobie, że zasięg globalny nie może być zasięgiem dynamicznym, jest zasięgiem statycznym, posiada metodę apply:
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class HiddenScope implements Scope
{
/**
* Apply the scope to a given Eloquent query builder.
*/
public function apply(Builder $builder, Model $model): void
{
$builder->where('hidden', false);
}
}
Ta metoda przyjmuje builder oraz model. I zasięg zostaje dopięty do modelu i jest aplikowany do każdego query na tym modelu. Nie może być dynamiczny, czyli mieć dodatkowych argumentów (które np. wstawimy do where), bo jest aplikowany domyślnie.
Dopinamy go w ten sposób:
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Models\Person;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Models\Scopes\HiddenScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
#[ScopedBy([HiddenScope::class])]
class Note extends Model
{
use HasFactory;
use SoftDeletes;
protected $fillable = ['title', 'content', ];
public function author(){
return $this->belongsTo(Person::class, "person_id");
}
}
Czyli poprzez atrybut. A korzystamy z niego w ten sposób:
Artisan::command('notes-all', function () {
$cnt = Note::count();
$this->comment($cnt);
});
//88 a jest 89, jedna ukryta
Czyli samo się korzysta. Ograniczenie tego takie, że skoro samo się korzysta, to nie można przekazać dodatkowych argumentów poza Builderem i Modelem, zasięg nie może być dynamiczny (chyba że za dynamiczność uznamy, że może być stosowany na różnych modelach, choć to lekko naciągane i dopiero od niedawna w Laravelu).
Oczywiście zasięg globalny można wyłączyć z jakiegoś query:
Artisan::command('notes-all-with-hidden', function () {
$cnt = Note::withoutGlobalScope(HiddenScope::class)->count();
$this->comment($cnt);
});
//89 czyli tyle ile jest z ukrytymi włącznie
Globalny – domyślnie działa na wszystkich query danego modelu, chyba że go wyłączymy. Powiedzmy, że częściowo dynamiczny, bo poza Builderem przyjmuje też model jako argument, ale nic poza tym.
Zobaczmy raz jeszcze wywołanie z zasięgiem globalnym:
Artisan::command('notes-all', function () {
$cnt = Note::count();
$this->comment($cnt);
});
//88 a jest 89, jedna ukryta
I niby gdzie tu możemy coś przekazać, co ten scope odbierze? Nigdzie. Ma tylko Buildera i model, do którego został dopięty.
Ok, teraz zasięg lokalny:
use Illuminate\Database\Eloquent\Builder;
#[ScopedBy([HiddenScope::class])]
class Note extends Model
{
use HasFactory;
use SoftDeletes;
protected $fillable = ['title', 'content', ];
public function author(){
return $this->belongsTo(Person::class, "person_id");
}
public function scopeHiddenOnly(Builder $query): void
{
$query->withoutGlobalScope(HiddenScope::class)->where('hidden', true);
}
}
Zasięg lokalny:
- Zaczyna się od słowa scope
- Musi zostać zawołany specjalnie, inaczej nie zostanie użyty
- Może być statyczny (zawiera tylko Builder) lub dynamiczny (zawiera Builder i przyjmuje dodatkowe argumenty)
Ok, tutaj mamy zasięg, który pokazuje tylko ukryte notatki, czyli najpierw wyłącza globalny zasięg ukrywający ukryte, potem stawia warunek, że hidden MUSI być na true.
Oto użycie tego lokalnego, statycznego zasięgu:
Artisan::command('notes-hidden-only', function () {
$cnt = Note::hiddenOnly()->count();
$this->comment($cnt);
});
//1
Czyli scope resolution operator (::) nazwa metody (bez scope i pierwsze słowo małą) i dopiero wtedy jest wywołany. Dlatego jest lokalny. Jest też statyczny, ponieważ nic mu w tych nawiasach nie przekazujemy, a moglibyśmy.
Ok, a jak się tworzy zasięgi globalne „po staremu”? Komenda jest ta sama, ale podam przykład z dokumentacji:
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class AncientScope implements Scope
{
/**
* Apply the scope to a given Eloquent query builder.
*/
public function apply(Builder $builder, Model $model): void
{
$builder->where('created_at', '<', now()->subYears(2000));
}
}
Mamy zasięg. Teraz pora go dopisać do modelu:
<?php
namespace App\Models;
use App\Models\Scopes\AncientScope;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
/**
* The "booted" method of the model.
*/
protected static function booted(): void
{
static::addGlobalScope(new AncientScope);
}
}
Możemy też go nie tworzyć komendą jako klasę, tylko stworzyć closure dla jednego modelu:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
/**
* The "booted" method of the model.
*/
protected static function booted(): void
{
static::addGlobalScope('ancient', function (Builder $builder) {
$builder->where('created_at', '<', now()->subYears(2000));
});
}
}
Jak widać ten z closure nie musi modelu przyjmować, tylko builder. A po nowemu, raz jeszcze, jak tworzymy?
Przez atrybut ScopedBy:
<?php
namespace App\Models;
use App\Models\Scopes\AncientScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
#[ScopedBy([AncientScope::class])]
class User extends Model
{
//
}
Jeszcze raz:
- Zasięg globalny, czyli taki, który chodzi sam przy każdym query
- Zasięg globalny można wyłączyć dla danego query używając withoutGlobalScope
- Zasięg lokalny, czyli taki, który sami musimy wywołać
- Zasięg lokalny zaczyna się od słowa scope, zaś wołamy go bez słowa scope
- Lokalny może być statyczny bądź dynamiczny
- Dynamiczny to taki, który przyjmuje dodatkowe argumenty poza builderem
- Zasięg globalny ma dwa arugmenty – builder i model
- Zasięg globalny może mieć jeden argument (builder) jeżeli jest dodawany przez closure w booted (static::addGlobalScope)
- Zasięg globalny najlepiej i najnowocześniej jest dodać atrybutem scopedBy
Moim zdaniem atrybuty PHP są dużo czytelniejsze niż jakieś dziwne konstrukcje, gdzie w obiektówce zaawansowanej plus używającej frameworków czasami tak bywa, że kod i flow naszego programu nam ucieka i jakimiś dziwnymi torami płynie. Wpadamy w takie „obiektowe goto” i trudno nam to jakoś konceptualnie ugryźć.
Atrybuty zapewniają lepszą czytelność, plus warto umieć programować „po nowemu”, bowiem Laravel się zmienia, stare rzeczy bywają usuwane.
Na przykład teraz już nie można robić middleware w konstruktorze kontrolera (to jest można, ale nie przez this->middleware). To też była taka rzecz, która sprawiała, że kod nam gdzieś dziwnymi torami płynął, jak ja to mówię „obiektowe goto” i już tego nie ma w Laravelu.
Podobnie atrybuty modelów zostały potraktowane (akcesory, mutatory), kiedyś konwencja nazewnicza wystarczała, teraz trzeba je stworzyć ot choćby tak:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
/**
* Get the user's first name.
*/
protected function firstName(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucfirst($value),
);
}
}
Znaczy – musimy wiedzieć dokładnie co robimy, aby móc z nich skorzystać. Podobnie jest z Middleware w kontrolerze:
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Routing\Controllers\HasMiddleware;
use Illuminate\Routing\Controllers\Middleware;
class UserController extends Controller implements HasMiddleware
{
/**
* Get the middleware that should be assigned to the controller.
*/
public static function middleware(): array
{
return [
'auth',
new Middleware('log', only: ['index']),
new Middleware('subscribed', except: ['store']),
];
}
// ...
}
Frameworki idą w tym kierunku – jeżeli dobrze je znamy i wiemy co robimy, możemy zrobić naprawdę wiele. Ale koniec z uciekającym nam kodem, który działa nie wiadomo jak i dlaczego.