<? phpukraine СТАТТІ
Пошук по платформі
LARAVEL 15 вересня 2026 · 7 хв читання

Ролі, Gates і Policies в Laravel: як розкласти доступ без бібліотеки

Два enum-и, десяток policy-методів і один middleware закривають більшість задач з доступом у типовому застосунку. Розбираємо, коли роль у колонці краща за таблицю permissions, чому Policy належить агрегату, а Gate дії, де middleware дає лише ілюзію захисту і які тести на доступ мають бути обовʼязковими.

РP
Редакція phpukraine
Редакція платформи

Пакет ролей ставлять рефлекторно, одразу після скаффолду автентифікації. Через рік виявляється, що ролей у системі три, вони не змінювались жодного разу, а половина рішень про доступ узагалі не про роль: питання в тому, чи належить запис цьому користувачеві. У ядрі 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 отримає слово.

ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 15 вересня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті