The three-layer security model
Feature flag, ACL permission, ownership voter: a mutating route walks through three doors before touching the database.
A SaaS platform answers three distinct questions: is the module enabled for this tenant?, can this role perform this action?, does this user own this resource?. Merging them yields subtle bugs; separating them yields a clear model.
1. Feature flag — module-level
The class-level attribute #[RequiresFeature('cms.manage')] redirects to /access-denied when the organization has not enabled the module. One kill-switch covers every controller route, plus the sidebar via is_feature_enabled('cms.manage').
2. ACL permission — per role and per user
#[IsGranted(PermissionCodes::CMS_PAGE_UPDATE)] on every mutating method, including bulk routes. Permissions are seeded by role (ROLE_USER, ROLE_ORGANIZATION_ADMIN) and can be overridden user by user.
3. Voter — ownership + scope
Before every per-entity mutation: denyAccessUnlessGranted(PageVoter::EDIT, $page). The voter checks that $page->getOrganization() === $user->getOrganization(), that the resource is not soft-deleted, and that its status allows the operation.
Why all three?
A single failure does not compromise security. An attacker must clear the module (feature), the role (permission) and the resource (voter). This is defense in depth, and it is what separates a production-ready foundation from a prototype.