Try answering this in a compliance review: why exactly did your application let account 4127 approve that invoice back in March? Not whether it did, the access log settles that part. Why. Which rule fired, which role tipped the scale, what the voter actually saw at that moment. For most Symfony apps the honest answer has been a boolean and a shrug, because a voter returns true or false and takes its reasoning to the grave. That era is ending, and I think we should bury the workarounds with it.
security:
access_decision_manager:
strategy_service: App\Security\WeightedComplianceStrategyYou know the workaround I mean, because you probably wrote it. A $logger->info() call inside every voter, each one formatted slightly differently, none of them reliably linked to the decision the access manager eventually made. Audit string formatting sitting in the middle of authorization logic, which is roughly the last place it belongs. Symfony 7.3, shipped in May 2025 with PR #59771 from Nicolas Grekas, made that crutch obsolete: VoterInterface::vote() and Voter::voteOnAttribute() gained a nullable Vote argument, and a single $vote?->addReason('why I said no') puts the explanation right where the decision happens. Those reasons surface in the profiler, in log output and on exception pages, and the base Voter class writes the granted, denied or abstain result into the Vote on its own. Your subclass just explains itself.
Reasons are prose for humans. The part that changes how I design authorization arrived in November 2025 with 7.4 and 8.0: extraData, contributed by Roman Joly, Eltharin on GitHub, in PR #60085. A vote can now carry an array, or a whole object if you insist, of machine-readable payload. A score, a policy reference, a risk flag. Pair that with AccessDecisionStrategyInterface and the strategy_service config, both of which existed long before 7.4, and a custom strategy suddenly has something worth reading in decide(). Symfony voting has always been strictly egalitarian, every voter counting the same, which is a lousy fit the moment a CFO's approval should weigh more than an intern's on a large invoice. Now a voter can attach its weight, and one strategy can write one structured audit entry per request covering every vote cast. Add a tenth voter next sprint and the audit format does not move.
The template layer caught up too. Florent Destremau's PR #61379 adds access_decision() and access_decision_for_user() next to the boolean-only is_granted() functions. Both return an AccessDecision object exposing the strategy name, isGranted, the votes array and a getMessage() method, and the for_user variant checks a specific account rather than the current token, which is exactly what an admin screen previewing someone else's permissions needs. One trap deserves a highlight in red: getMessage() only stitches together reasons from votes that agree with the final outcome. A voter that dissented or abstained vanishes from that message no matter how carefully it explained itself. For the complete picture you still open the profiler.
Now the concession, because it is a real one. Most applications are perfectly served by the affirmative strategy and a plain yes or no, and none of the built-in strategies, affirmative, consensus, unanimous or priority, read extraData at all. Write scores into your votes while running affirmative and you have produced beautifully structured dead data that changes nothing. There is a genuine risk of building a small rules engine nobody asked for, with weights nobody can justify in a code review. And a reason string written for an internal profiler tab can name a business policy you absolutely do not want rendered into a customer-facing page. Piping decision.message straight into Twig without thinking about the second audience is a new bug class, freshly available.
I still land firmly on the side of adopting the Vote object, and the argument is not compliance, it is Tuesday afternoon debugging. Seven voters registered, one request returns 403, and before 7.3 you got to bisect them with dumps or breakpoints while a colleague waited. addReason() costs one line per branch and turns that hour into a glance at the profiler. My Go-writing colleagues assemble this kind of decision trace by hand out of context values and middleware, and they consider it normal. We get it as a framework primitive with a nullable argument. I will take that trade every time.
Version math, briefly. extraData and the two Twig functions require 7.4 or 8.0. Symfony 7.4 is the LTS, runs on PHP 8.2 and up, and gets bug fixes until November 2028 with security patches through November 2029; 8.0 wants PHP 8.4 and rides a shorter cycle, supported until July 2026. If you are parked on 7.3 you already have addReason() and should be using it today. My adoption rule is simple: reasons everywhere, immediately, because they cost nothing. extraData only on the day a custom strategy actually reads it.
Which leaves the question I keep turning over: where do you draw the exposure line? Is decision.message strictly a back-office artifact for admins and auditors, or would you ever show a filtered version to the end user who just got blocked, so they know it was the invoice threshold and not a bug? Tell me where your team lands, and whether anyone out there has genuinely needed weighted votes in production rather than unanimous plus a well-written reason.




Comments
No comments yet — be the first.
Open the discussion
No account or password needed — just enter your e-mail and we’ll send you a one-time sign-in link. First time here? You’re set up automatically.
Your rating will be applied automatically after you sign in.
Check your inbox
We’ve sent a sign-in link to …. Open it on this device — this tab will sign you in automatically.
Nothing arrived? Check your spam folder — and mark the mail as "Not spam" so it lands in your inbox next time.