Three answers, not two

A voter that does not understand the question must say so rather than deny. A denial is an opinion, and silence is not, and treating the two alike is how an authorization model quietly closes doors nobody meant to close.

A voter in Access Control answers with an AccessOutcome, and there are three of them.

AccessOutcome::grant('The user owns the post.');
AccessOutcome::deny('Only the author may edit.');
AccessOutcome::abstain('This voter knows nothing about posts.');

Abstaining is a result of its own

The three-valued answer is not a nicety of the API, it is what lets several voters share one question without fighting over it. A voter about documents is asked about a workflow transition, a voter about roles is asked about an ownership check, and the honest answer in both cases is that they have nothing to say.

Return a denial there and you have expressed an opinion you do not hold. Under deny_overrides, that opinion binds the decision for everybody else, and the door closes for a reason that never existed.

Coming from a boolean

A Symfony Security voter extending Voter returns a boolean from voteOnAttribute(), and false there means deny. The abstention exists in that model too, but it is expressed elsewhere, by supports() answering false.

That works until applicability depends on something supports() cannot see. The classic case: a voter that only knows about a certain kind of requester. supportsSubject() receives the subject, not the request, so the voter has to claim the subject and then abstain in vote(). With a boolean, there is nowhere to say it.

if (! $requester instanceof User) {
    return AccessOutcome::abstain('This voter only knows about users.');
}

What happens when everybody abstains

Silence has to resolve to something in the end, and that something is settled once, by configuration, rather than by each voter guessing. allow_if_all_abstain defaults to false, so a question nobody answered is refused, and a single request may override it when it means to.

The important part is where that setting lives: on the manager, not on each combining algorithm. One setting obeyed by every entry point, rather than by the ones that remembered to pass it.

Reading the three back

A decision exposes isGranted(), which answers false for an abstention exactly as it does for a refusal: that is what every entry point already does with one. When you need to tell the two apart, read decision, which is a DecisionVote enum and keeps the abstention intact.

In tests, assert the abstention rather than settling for “not granted”. The component ships assertAccessAbstained() for exactly that, because the two failures it distinguishes are the ones you want to see in a diff.