A new component, and one question: is this requester allowed to perform this action on this subject? There is no user in its vocabulary, no token and no firewall, and that is the point.
Access Control is authorization, and nothing else. Authentication is a separate concern with a separate answer, and this component never asks it. It carries voters, combining algorithms, access policies declared as attributes, and decisions that say why they were reached.
Why it exists
Authorization in a PHP application tends to end up spread across places that do not know about each other: voters in one component, URL rules in a firewall, an attribute on a controller, an expression in a workflow guard, a function in a template. Each of them re-implements a piece of the same idea, and they drift.
This component holds the idea once. Everything else, the controller attribute, the URL rule, the template function, the console guard, the workflow guard, is an entry point onto the same machinery.
$decision = $accessControlManager->decide(new AccessRequest($requester, 'EDIT', $post));
if (! $decision->isGranted()) {
throw new AccessDeniedException($decision->reason);
}
The same question, asked from a controller of a Symfony application:
#[AccessPolicy('EDIT', new Argument('post'))]
public function edit(Post $post): Response
It does not need an authentication stack
The requester is typed mixed: a Symfony token when you have one, and otherwise a service account, a machine actor, an API key, or a string. A voter that does not understand a requester abstains, which is the whole protocol.
symfony/security-core is optional throughout, and the classes that know about it live in a bridge namespace of their own. An application that has Security keeps it and changes nothing; an application that has none can express everything the same way.
It says why
A decision is not a boolean. It carries the verdict, the reason, and who voted which way. That is what makes a refusal diagnosable six months later, and it is what the profiler panel, the log and the test assertions all read.
A denial reaching an HTTP response is still a bare 403: the diagnostic is available to the developer and withheld from the visitor.
The failure it is built against
A guard that lets somebody through is the only unacceptable failure, and it is silent. Nothing in a test suite goes red, nothing in a log goes wrong; access rules simply stop applying.
So the component is built to fail loudly instead. What the Symfony bridge cannot absorb raises at compile time rather than at runtime: two combining algorithms named at once, two sets of URL rules, two role hierarchies, contradictory settings. What a reprise cannot do refuses out loud rather than answering false, because a refusal a template cannot tell from a real one is the same family of fault.
Two packages, one repository
- spomky-labs/access-control-lib, the library, usable in any PHP application.
- spomky-labs/access-control-bundle, the integration into the Symfony full-stack framework.
Both are read-only subtree splits of access-control-framework, where issues and pull requests belong. The documentation lives at acf.spomky-labs.com, and a demonstration application puts every one of these ideas behind a real page.
Installing the bundle in an application that has Symfony Security changes nothing you can see: your voters keep being consulted, #[IsGranted] is read, is_granted() answers in your templates, and your security.yaml stays byte for byte what it was. What changed is who decides.
Over the coming weeks, one article a day will take one piece of it: the vocabulary, the voters, the combining algorithms, the entry points, the migration, the tests, and the demo. This was the shape of the thing; the rest is the detail.