Organizations
Multi-tenant architecture with role management, invitations and context switching.
Overview
The boilerplate supports a multi-tenant architecture: a user can belong to multiple organizations with distinct roles. Each request is automatically scoped to the active tenant via TenantContext.
| Feature | Detail |
|---|---|
| Organization settings | src/Controller/Settings/OrganizationController.php |
| Member management | src/Controller/Settings/MembersController.php |
| Public invitation flow | src/Controller/Invitation/InvitationController.php |
| Context switcher | src/Controller/Account/SwitchOrganizationController.php |
| Organization creation & update | src/Service/Organization/OrganizationService.php |
| Invitation lifecycle | src/Service/Organization/InvitationService.php |
| Tenant context (request-scoped) | src/Service/Organization/TenantContext.php |
| Tenant resolution on kernel.request | src/EventSubscriber/TenantSubscriber.php |
| RBAC — per-organization permissions | src/Security/Voter/OrganizationVoter.php |
| Invitation email | src/Mail/Organization/InvitationMailer.php |
| Twig function user_organizations() | src/Twig/Runtime/OrganizationRuntime.php |
Roles
Each organization member has a role that determines their rights:
| Role | Permissions |
|---|---|
| owner | Full access — deletion, ownership transfer |
| admin | Manage members, edit settings |
| member | Access to business features only |
RBAC — OrganizationVoter
Permissions are enforced via Symfony Voters. OrganizationVoter defines 6 attributes, with the organization as subject:
src/Security/Voter/OrganizationVoter.php
| Attribute | Allowed roles |
|---|---|
ORG_SETTINGS | Owner, Admin |
ORG_INVITE | Owner, Admin |
ORG_MANAGE_MEMBERS | Owner, Admin |
ORG_REVOKE_INVITATION | Owner, Admin |
ORG_DELETE | Owner only |
ORG_TRANSFER_OWNERSHIP | Owner only |
Usage
In a controller (throws AccessDeniedException if denied):
$this->denyAccessUnlessGranted(OrganizationVoter::ORG_SETTINGS, $organization);
In a Twig template (hides elements based on role):
{% if is_granted('ORG_INVITE', organization) %}
Organization settings
Owners and admins can edit the name and slug from /settings/organization. Access is protected by ORG_SETTINGS.
src/Controller/Settings/OrganizationController.php
The slug is auto-generated at creation via cocur/slugify (4-character random suffix on collision). It can be edited manually with uniqueness validation.
src/Service/Organization/OrganizationService.php
Member management
The /settings/members page allows inviting members, viewing pending invitations and acting on active members. Each POST action is protected by the corresponding voter:
src/Controller/Settings/MembersController.php
| Action | Required voter |
|---|---|
| Invite a member | ORG_INVITE |
| Revoke an invitation | ORG_REVOKE_INVITATION |
| Suspend a member | ORG_MANAGE_MEMBERS |
| Reactivate a member | ORG_MANAGE_MEMBERS |
Invitation flow
Email invitation is the only way to join an existing organization. The token is generated via bin2hex(random_bytes(32)), valid for 7 days.
- The owner or admin submits the email and role — InvitationService::invite() generates the token, checks for duplicates (existing member or pending invitation) and dispatches MemberInvitedEvent.
- SendInvitationEmailSubscriber receives the event and InvitationMailer sends the email with the /invitation/{token} link.
- The invitee clicks the link. If not logged in, the token is stored in session (_pending_invitation_token) and they can log in or create an account (pre-filled form, no organization creation).
- Acceptance calls InvitationService::accept() which creates the OrganizationMember, marks the invitation as accepted and dispatches MemberJoinedEvent. The tenant context automatically switches to the new organization.
Registration via invitation
When the invitee creates an account, the registration form detects the _pending_invitation_token session and adapts: email pre-filled and locked, organization field hidden, no organization created. After email verification, the user is redirected to the invitation page.
src/Controller/Invitation/InvitationController.php
Rate limiting
10 invitations per hour per organization (token bucket). Configurable in:
config/packages/framework.yaml
Tenant context
On every request, the active organization is resolved and stored in TenantContext. Repositories inject this service to automatically scope their queries to the current tenant.
TenantContext
Request-scoped service that holds the active organization for the current HTTP request.
src/Service/Organization/TenantContext.php
| Method | Description |
|---|---|
getCurrentOrganization() | Returns the current organization, or null if none is set. |
setCurrentOrganization() | Sets the active organization (called by TenantSubscriber). |
requireCurrentOrganization() | Returns the current organization or throws a LogicException. Use this in services/repositories where a tenant is mandatory. |
hasOrganization() | Returns true if an organization is set. |
TenantSubscriber
Runs on kernel.request (priority 0, after the firewall). Reads the ID from the session, verifies the active membership, and injects it into TenantContext. If no organization is in session, the first active membership is auto-selected.
src/EventSubscriber/TenantSubscriber.php
Session key: _current_organization_id
Inter-organization context switch
A user can belong to multiple organizations. The navbar selector allows switching between them without logging out.
src/Controller/Account/SwitchOrganizationController.php
The POST route /account/switch-organization/{id} verifies the active membership, writes _current_organization_id to the session and redirects to the previous page (via the Referer header).
Twig function
The user_organizations() function exposes all active memberships for the current user. It is available globally via OrganizationExtension + OrganizationRuntime:
{% set memberships = user_organizations() %}
src/Twig/Runtime/OrganizationRuntime.php
Danger zone
The /settings/danger page exposes two irreversible actions restricted to owners: delete the organization and delete the account.
src/Controller/Account/DangerZoneController.php
Organization deletion
Protected by the ORG_DELETE voter (owner only). The user must type the exact organization name to confirm. OrganizationDeletionService deletes subscriptions, then the organization (cascades members + invitations).
src/Service/Organization/OrganizationDeletionService.php
Account deletion
The user must confirm their password. AccountDeletionService checks for sole-owner status before deleting memberships, the reset token, and then the account.
Sole-owner block: if the user is the only owner of at least one organization, deletion is blocked. The list of blocking organizations is shown in the UI.
src/Service/Auth/AccountDeletionService.php