Пакет ролей ставлять рефлекторно, одразу після скаффолду автентифікації. Через рік виявляється, що ролей у системі три, вони не змінювались жодного разу, а половина рішень про доступ узагалі не про роль: питання в тому, чи належить запис цьому користувачеві. У ядрі Laravel для цього є Gate, Policy, middleware can і authorizeResource. Далі розбираємо, як розкласти їх так, щоб через рік було видно, хто що може, і щоб це можна було перевірити тестом.
Роль у колонці проти таблиці permissions
Питання зводиться до одного: чи мусить набір дозволів змінюватися в рантаймі, без деплою? Якщо в адмінці має бути екран «створити роль і поставити галочки», потрібні таблиці roles, permissions, permission_role і кеш поверх них. Якщо ролей фіксована кількість, а їхні права описує ваш же код, ці таблиці дадуть кілька join-ів і нуль користі.
Для другого випадку достатньо колонки з enum і другого enum для дозволів:
enum Permission: string
{
case PanelAccess = 'panel.access';
case ArticleReview = 'article.review';
case ArticlePublish = 'article.publish';
case JobModerate = 'job.moderate';
case JobCreate = 'job.create';
case LeadView = 'lead.view';
}
enum Role: string
{
case Admin = 'admin';
case Editor = 'editor';
case Employer = 'employer';
case Developer = 'developer';
/** @return list<Permission> */
public function permissions(): array
{
return match ($this) {
self::Admin => Permission::cases(),
self::Editor => [Permission::PanelAccess, Permission::ArticleReview, Permission::ArticlePublish, Permission::JobModerate],
self::Employer => [Permission::JobCreate, Permission::LeadView],
self::Developer => [],
};
}
}
У моделі користувача:
protected function casts(): array
{
return ['role' => Role::class];
}
public function hasPermission(Permission $permission): bool
{
return in_array($permission, $this->role->permissions(), true);
}
Що це дає практично: match без default змушує компілятор сварити вас при додаванні нової ролі, PHPStan бачить помилку в назві дозволу, а grep Permission::ArticlePublish знаходить усі місця, де це право реально використовується. Жодного запиту в базу, щоб дізнатися права, і жодного кешу, який треба інвалідувати.
Ціна теж конкретна: зміна матриці прав вимагає коміту й деплою. Для продукту з фіксованими ролями це нормально, для white-label з довільними ролями клієнта категорично ні.
Ще одна річ, яку легко змішати: глобальна роль і роль у межах агрегату живуть окремо. Користувач може бути Employer у системі й водночас власником однієї компанії та звичайним учасником іншої. Друге живе не в колонці users.role, а в pivot-таблиці company_user з власною колонкою ролі, і питають про нього policy, а не глобальні дозволи.
Policy на агрегат, Gate на дію
Policy привʼязана до класу моделі, і це підказка, як її розміщувати: одна policy на корінь агрегату. Company, Job, Article. Усе, що звучить як «чи може цей користувач зробити X саме з цим записом», лягає в policy-метод з іменем дії ресурсу: viewAny, view, create, update, delete плюс свої, як-от publish чи invite.
Автодискавері шукає App\Policies\CompanyPolicy для App\Models\Company. Якщо моделі лежать не в App\Models (типова ситуація для модульної структури), додайте атрибут над моделлю:
#[UsePolicy(CompanyPolicy::class)]
class Company extends Model { /* ... */ }
Замість атрибута можна взяти Gate::guessPolicyNamesUsing() з власним правилом, але атрибут читабельніший і не ламається на нестандартних іменах.
Сама policy має лишатися тонкою. Правило «редагувати профіль можна лише після верифікації» належить домену, policy його питає:
final class CompanyPolicy
{
public function view(User $user, Company $company): bool
{
return $user->hasPermission(Permission::PanelAccess) || $company->hasMember($user->id);
}
public function update(User $user, Company $company): Response
{
if (! $company->hasOwner($user->id)) {
return Response::denyAsNotFound();
}
return $company->isVerified()
? Response::allow()
: Response::deny('Профіль можна редагувати після верифікації компанії.');
}
}
Повернення Response замість bool вирішує дві дрібні, але дратівливі задачі. denyAsNotFound() віддає 404 там, де сам факт існування запису є приватною інформацією: чужу компанію користувач не має «бачити як заборонену», він має її не бачити взагалі. А deny() з текстом дає користувачеві причину, і цей текст можна дістати без винятку через Gate::inspect('update', $company)->message().
Gate лишається для дій, у яких немає одного власника-агрегату: доступ до розділу панелі, експорт зведеного звіту, перемикач адмінської фічі.
Gate::define('panel.access', fn (User $user): bool => $user->hasPermission(Permission::PanelAccess));
Зі скелета Laravel давно прибрали AuthServiceProvider, тож виклики Gate::define за замовчуванням живуть у AppServiceProvider::boot(). Коли їх стає більше пʼяти, зробіть окремий провайдер і зареєструйте його в bootstrap/providers.php: шукати правила доступу у файлі, де ще й Vite з Blade-директивами, незручно.
Просте правило для вибору: якщо в сигнатурі перевірки є конкретний запис, це policy. Якщо є лише користувач, це gate.
Middleware закриває розділ, а не запис
Розділ цілком закривається на групі маршрутів:
Route::middleware(['auth', 'can:panel.access'])
->prefix('panel')
->group(base_path('routes/panel.php'));
Аліас can зареєстрований у ядрі, окремо його прописувати не треба. Для маршрутів з моделлю є коротший запис, який спирається на implicit binding:
Route::put('/companies/{company}', CompanyUpdateController::class)
->can('update', 'company');
Другим аргументом іде саме імʼя параметра маршруту, рядком. Якщо там буде companies, middleware не знайде привʼязану модель і спробує викликати policy-метод для класу, тобто перевірку на кшталт create. Помилка тиха, тому такі маршрути мають бути покриті тестом.
Для ресурсних контролерів усе робить authorizeResource(Company::class, 'company') у конструкторі: index мапиться на viewAny, show на view, store на create, update та edit на update, destroy на delete.
Межа, об яку регулярно спотикаються: middleware і policy відповідають на питання «чи можна», але нічого не фільтрують. viewAny пропустить користувача до списку компаній і не звузить вибірку до його власних. Це робота scope у запиті, і вона окрема:
$companies = Company::query()->whereBelongsToUser($user)->paginate();
Перевірка на рівні запису потрібна навіть тоді, коли розділ закритий middleware. Роль Editor дає доступ до панелі загалом, але не право публікувати чужу статтю.
Тести на доступ: мінімальний обовʼязковий набір
Позитивний тест («власник може редагувати») майже завжди пишуть, і він майже ніколи не ловить регресію. Ловлять негативні.
На кожен policy-метод потрібні три випадки: дозволено тому, кому має бути; заборонено автентифікованому користувачеві без прав; перенаправлено гостя на логін. Останній перевіряє, що на маршруті взагалі є auth, а не тільки can.
it('не дає редагувати чужу компанію', function () {
$company = Company::factory()->create();
$stranger = User::factory()->create(['role' => Role::Employer]);
$this->actingAs($stranger)
->put(route('companies.update', $company), ['name' => 'Acme'])
->assertNotFound();
expect($company->refresh()->name)->not->toBe('Acme');
});
Дві деталі в цьому тесті важать більше, ніж здається. По-перше, статус перевіряється саме той, який обрано в policy: якщо там denyAsNotFound(), а тест чекає assertForbidden(), ви дізнаєтесь про підміну відповіді одразу, а не з баг-репорта. По-друге, після заблокованого запиту перевіряється стан у базі. Був випадок, і не один, коли контролер виконував запис до того, як доходив до authorize().
Другий обовʼязковий тест структурний, він страхує від забутої перевірки на новому маршруті:
it('кожен маршрут панелі вимагає auth і дозволу', function () {
$routes = collect(Route::getRoutes()->getRoutes())
->filter(fn ($route) => str_starts_with($route->uri(), 'panel'));
expect($routes)->not->toBeEmpty();
$routes->each(function ($route) {
expect($route->gatherMiddleware())
->toContain('auth')
->toContain('can:panel.access');
});
});
Він не замінює перевірок доступу до записів, але ловить типову ситуацію, коли маршрут дописали поза групою.
Для комбінаторики ролей зручні датасети Pest: один тест, набір пар «роль плюс очікуваний статус». Перевіряти при цьому краще HTTP-рівень, а не Gate::allows() в ізоляції. Unit-тест на policy підтверджує, що метод повертає false, і мовчить про те, що контролер його не викликає. Мокати сам Gate у тестах на доступ немає сенсу: ви тоді тестуєте мок.
Пастка, яку варто знати заздалегідь
Gate::before виглядає як елегантний спосіб дати адміністратору все:
Gate::before(fn (User $user) => $user->role === Role::Admin ? true : null);
Працює, але має неприємний побічний ефект: будь-який тест з адміністратором після цього проходить незалежно від того, чи правильна policy й чи взагалі вона існує. Якщо фабрика користувача за замовчуванням створює адміна, зелений набір тестів на авторизацію нічого не доводить. Тому дефолтна роль у фабриці має бути найслабшою, а Gate::before варто застосовувати свідомо: він обходить усі policy, включно з тими, де ви навмисно заборонили дію всім, наприклад редагування контенту, чиє джерело у файлах репозиторію.
Повертати з before треба null, а не false, коли правило не застосовується. false тут означає остаточну заборону і зупиняє ланцюжок до того, як policy отримає слово.