Чотири назви з питання описують один механізм, до якого дотягуються з різних точок запиту. Будь-яка перевірка врешті доходить до Gate::raw(), і порядок там фіксований: спершу глобальні колбеки Gate::before() (перший не-null результат обриває все інше), потім спроба знайти policy за першим аргументом, потім іменована здібність з Gate::define(), і вже на виході Gate::after(). Policy знаходиться без реєстрації: guessPolicyName() бере клас моделі, міняє сегмент \Models\ на \Policies\ і додатково перебирає неймспейс угору, тому App\Models\Post дає App\Policies\PostPolicy. Зі структурою поза стандартом (модулі, DDD, моделі поза App\Models) конвенція не спрацьовує, і тоді є три виходи: атрибут #[UsePolicy(PostPolicy::class)] на моделі, явний Gate::policy() або власне правило через Gate::guessPolicyNamesUsing().
Найчастіше валяться на наступному кроці. resolvePolicyCallback() спочатку перевіряє, чи взагалі є в policy метод під цю здібність, і якщо ні, повертає false, тобто Gate іде шукати іменовану здібність. Немає і її - відмова, 403, жодного винятку й жодного запису в логах. Звідси класична загадка «policy є, метод є, а прав немає»: здібність називається update, а метод хтось назвав edit. З того ж факту випливає поведінка before() у самій policy: він викликається всередині вже побудованого policy-колбека, тому для здібності без відповідного методу не спрацює. Адмінський обхід, який має покривати справді все, живе в глобальному Gate::before(), а не в policy. Гості - окрема історія: метод із типом User $user для null не викликається взагалі, результат null перетворюється на deny, і користувач без сесії отримує 403 замість пропозиції залогінитись. Хочете пускати гостя в перевірку - пишіть ?User $user.
can:update,post - це аліас Illuminate\Auth\Middleware\Authorize, і працює він до створення контролера. Аргумент він розбирає просто: рядок зі зворотним слешем вважається іменем класу (can:create,App\Models\Post для create і viewAny, де моделі ще немає), решта шукається серед параметрів маршруту. Не знайшлося - у Gate піде null, policy за null не знайдеться, і ендпоїнт почне віддавати 403 усім підряд; перейменування {post} на {article} без правки middleware дає рівно цей ефект. Для ресурсних контролерів усе це розвішує authorizeResource(Post::class, 'post') за мапою index -> viewAny, show -> view, create/store -> create, edit/update -> update, destroy -> delete. Метод $this->authorize() у контролері потребує трейта AuthorizesRequests, якого в порожньому базовому Controller з Laravel 11+ уже немає: або додайте трейт, або викликайте Gate::authorize().
Form Request зводить доступ і валідацію в один клас, і тут головне - порядок. validateResolved() робить prepareForValidation(), далі passesAuthorization(), і лише потім будує валідатор. Звідси два наслідки. Відмова в доступі дає 403, а rules() не виконуються, тому в тестах, де очікували 422 зі списком полів, ви побачите порожню відмову. І навпаки, перевіряти можна по тілу запиту, бо дані вже під рукою: дозволити зміну status на published тільки редактору, а решту полів лишити автору. Модель у маршруті на цей момент уже підставлена біндингом, тож $this->route('post') віддає обʼєкт, а 404 для неіснуючого id трапився раніше. Побутове: make:request у Laravel 11-13 генерує authorize(): bool { return false; }, і забутий return true виглядає як незрозумілий 403 на кожну відправку форми; а $this->user()->can(...) без auth у маршруті - це 500 на null. Якщо ж метод authorize() у класі просто не оголошений, passesAuthorization() пропускає запит далі, бо перевіряє його наявність через method_exists().
@can('update', $post) у Blade компілюється у виклик Gate::check() і вирішує єдине питання: показувати кнопку чи ні. Захисту він не дає, і саме про це питають «з підступом» - сховали кнопку, забули policy на PUT, отримали IDOR. Зі списками policy теж не допоможе: вона відповідає на питання про конкретний обʼєкт, а index треба звужувати запитом (->where('author_id', $user->id) або скоуп тенанта). Останнє, за що ставлять плюс, - усвідомлений вибір коду відповіді. 403 повідомляє співрозмовнику, що запис існує, просто він не ваш; для чужих чернеток, приватних профілів і чужих тенантів це підказка, яка дозволяє перебирати id, тому policy повертає Response::denyAsNotFound() (обробник винятків бачить статус у AuthorizationException і віддає 404), або модель узагалі не знаходиться - через вибірку від користувача чи Route::scopeBindings(). А для колеги в тій самій компанії, якому не вистачає ролі, чесніший 403 з текстом: 404 тут перетворює зрозумілу відмову на звернення в підтримку про «зламаний сайт».
// App\Models\Post -> App\Policies\PostPolicy знаходиться без реєстрації.
final class PostPolicy
{
// Викликається лише для здібностей, під які в policy є метод.
public function before(User $user, string $ability): ?bool
{
return $user->is_admin ? true : null; // null -> перевірка йде далі
}
// ?User: метод спрацює і для гостя. З типом User гість отримав би deny.
public function view(?User $user, Post $post): Response
{
return $post->is_published || $post->author_id === $user?->id
? Response::allow()
: Response::denyAsNotFound(); // 404: чужої чернетки для вас не існує
}
public function update(User $user, Post $post): Response
{
return $post->author_id === $user->id
? Response::allow()
: Response::deny('Редагувати можна лише власні статті.'); // 403 з текстом
}
}
final class UpdatePostRequest extends FormRequest
{
// Виконується перед rules(): відмова -> 403, валідація не запускається.
public function authorize(): bool
{
// Модель уже підставлена біндингом, тож 404 для неіснуючого id стався раніше.
return $this->user()->can('update', $this->route('post'));
}
/** @return array<string, ValidationRule|array<mixed>|string> */
public function rules(): array
{
return ['title' => ['required', 'string', 'max:120']];
}
}
// auth перед can: інакше гість отримає 403 замість редіректу на логін.
// 'post' має точно збігатися з іменем параметра маршруту.
Route::put('/posts/{post}', [PostController::class, 'update'])
->middleware(['auth', 'can:update,post']);
Скажіть, що Gates, Policies, middleware і Form Request - це не чотири механізми, а одна перевірка в чотирьох місцях запиту, і назвіть ці місця по порядку: маршрут (`can`), резолв Form Request (`authorize()`), тіло контролера (`Gate::authorize()`), шаблон (`@can` - лише для вигляду). Другим ходом додайте те, про що зазвичай не питають прямо: 403 повідомляє, що обʼєкт існує, тому для чужих чернеток є `Response::denyAsNotFound()`.