In Laravel, how do Response::deny, denyWithStatus, denyAsNotFound and Gate::inspect change what a denied authorization check tells the caller?
answer
- policy returns a Response, not a bool
- message propagates to the 403
- 404 hides that the record exists
- inspect() returns without throwing
- Gate::defaultDenialResponse()
basics
~10 sA policy can return Illuminate\Auth\Access\Response: deny('message') keeps 403 with a custom message, denyWithStatus() picks another status, denyAsNotFound() returns 404 to hide existence. Gate::inspect() returns that Response without throwing, for reading the message.
solid answer
~40 sReturning `false` produces a generic 403 with "This action is unauthorized." Returning a `Response` carries more: `Response::deny('Only the assigned reviewer may comment.')` keeps 403 but sends that message; `Response::denyWithStatus(409)` or `denyAsNotFound()` makes `Gate::authorize()` throw an `AuthorizationException` carrying that status, which the handler renders as that HTTP code. `denyAsNotFound()` is the usual choice when a user should not learn that a draft exists at all. `Gate::allows()` still returns a plain bool; `Gate::inspect()` returns the full `Response` without throwing, so code can show `$response->message()` in the UI. `Gate::defaultDenialResponse(Response::denyAsNotFound())` changes what a plain `false` becomes, application-wide. A policy may also throw `AuthorizationException` itself; `inspect()` converts it back into a `Response`.
code
php · 28 lines<?php
namespace App\Policies;
use App\Models\Article;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class ArticlePolicy
{
public function view(User $user, Article $article): Response
{
if ($article->published_at !== null || $article->author_id === $user->id) {
return Response::allow();
}
return $user->assignedReviews()->whereKey($article->id)->exists()
? Response::allow()
: Response::denyAsNotFound();
}
public function comment(User $user, Article $article): Response
{
return $article->reviewer_id === $user->id
? Response::allow()
: Response::deny('Only the assigned reviewer may comment.');
}
}go deeper
Remember that a denied check is 403 by default and that a policy may return Response::deny with a message.
Explain how denyWithStatus and denyAsNotFound travel through AuthorizationException, and when to use inspect instead of allows.
Choose 404 versus 403 per resource to avoid leaking existence, and surface denial reasons in the UI with inspect.
Standardise denial semantics across the API so clients and auditors can rely on consistent statuses and messages.
## Booleans versus responses A gate or policy method may return: - `true` / `false`; - `null` (treated as deny, or used by `before`/`after` to defer); - an `Illuminate\Auth\Access\Response`. Booleans are enough to allow or refuse. A `Response` lets the rule also say **why** and **with which status**. ## The Response constructors | Constructor | Effect when the check is enforced | |---|---| | `Response::allow()` | allowed | | `Response::deny('msg')` | `AuthorizationException` → 403 with `msg` | | `Response::denyWithStatus(409, 'msg')` | exception carries status 409 → rendered as 409 | | `Response::denyAsNotFound()` | exception carries status 404 → rendered as 404 | `Response::authorize()` is what throws: `Gate::authorize()` calls `inspect()` and then `authorize()` on the result, creating an `AuthorizationException` with the response's message, code and status. The exception handler turns an exception **with** a status into an HTTP exception of that status, and one **without** into a 403. ## Choosing 404 over 403 On a publishing platform, drafts are private to their author and assigned editors. If a reviewer probes `/articles/913` and gets 403, they learn article 913 exists. Returning `Response::denyAsNotFound()` from `view()` makes an unauthorized draft indistinguishable from a missing one. Use it consistently: if `view` hides existence but `update` answers 403, the difference leaks it anyway. ## `Gate::inspect()` versus `allows()` - `Gate::allows()` / `denies()` / `check()` reduce everything to a boolean. - `Gate::inspect('update', $article)` returns the `Response` itself, never throwing. It also catches an `AuthorizationException` thrown inside a policy and converts it to a `Response`. - Useful for UIs: show "Only the section editor can publish this article" next to a disabled button instead of hiding it silently. ```php $response = Gate::inspect('publish', $article); if ($response->denied()) { return back()->with('status', $response->message()); } ``` ## App-wide defaults `Gate::defaultDenialResponse(Response::denyAsNotFound())` replaces the `Response::deny()` that a plain `false` or `null` result becomes. It does not alter policies that already return their own `Response`. ## Messages and safety 1. A denial message is shown to the user, so write it for them and keep internals out of it. 2. Do not put the reason a record is hidden into a 404 message; that re-leaks what the 404 hides. 3. Keep the status consistent per resource, so clients can handle it predictably. 4. Rendering of the exception itself (JSON versus HTML, custom pages) belongs to the exception handler. ## Inline checks `Gate::allowIf(fn (User $user) => ...)` and `Gate::denyIf(...)` authorize on the spot without a named gate; they throw `AuthorizationException` on denial (including when no one is logged in) and skip `before` and `after` hooks. ## A denial policy for the publishing platform | Situation | Response | Why | |---|---|---| | Reviewer opens an unassigned draft | `denyAsNotFound()` | do not reveal the draft exists | | Writer edits a published article they wrote | `deny('Published articles are locked; ask an editor.')` | the article is visible, so explain | | Editor publishes an article still under review | `denyWithStatus(409, 'Review is not finished.')` | a state conflict rather than a permission gap | | Anyone else | plain `false` → 403 | the default is fine | Keep the table in the team's docs: API clients and the frontend both depend on these statuses.
- A policy returns Response::deny('Not your section.') and the controller calls Gate::allows(). What does the controller see?Just `false`. `allows()` reduces the response to its allowed flag and discards the message. Use `Gate::inspect()` to read the message, or `Gate::authorize()` to let the exception carry it into the 403 response.
- Does Gate::defaultDenialResponse() change a policy that returns Response::deny('msg')?No. The default is used only when the check's result is not a `Response`, i.e. a plain `false` or `null`. A policy that returns its own `Response` keeps its message and status.
saying these in an interview costs you the question
- Gate::allows exposes the policy's denial message
- denyAsNotFound deletes or hides the record in the database
- A policy can only return true or false
- Gate::inspect throws when the check is denied
- defaultDenialResponse overrides policies that return their own Response