Organizations & invitations
Creating an organization
use Derafu\IdentityBundle\Contract\Service\OrganizationManagerInterface;
class CreateOrgController
{
public function __invoke(
OrganizationManagerInterface $orgManager,
RoleRepository $roleRepo
): Response {
$ownerRole = $roleRepo->findOneBy(['code' => 'org.owner']);
$org = $orgManager->create(
owner: $this->getUser(),
name: 'Derafu',
ownerRole: $ownerRole
);
// $org is persisted with a membership (role = org.owner)
return new JsonResponse(['id' => $org->getId()]);
}
}
create() is transactional: the organization and the owner-membership
are persisted atomically. Emits OrganizationCreated.
Adding members directly
$membership = $orgManager->addMember($org, $user, $memberRole);
Throws OrganizationOperationException if the user is already a
member. Emits OrganizationMemberAdded.
Removing members
$orgManager->removeMember($membership);
Throws OrganizationOperationException if the membership is the
owner (role.code = 'org.owner'). Transfer ownership first. Emits
OrganizationMemberRemoved.
Transferring ownership
$adminRole = $roleRepo->findOneBy(['code' => 'org.admin']);
$orgManager->transferOwnership($org, $newOwner, demoteCurrentOwnerTo: $adminRole);
The new owner must already be a member. The current owner is demoted to
$demoteCurrentOwnerTo — the caller controls the demotion policy
(admin, member, a custom role). Both role changes happen in a single
transaction. Emits OrganizationOwnershipTransferred with the previous
and new owners.
Finding the owner
$owner = $orgManager->getOwner($org); // ?UserInterface
Scans memberships for role.code = 'org.owner'. Returns null if none
found (should not happen if the invariant is maintained).
Invitations
Invite by email
use Derafu\IdentityBundle\Contract\Service\InvitationManagerInterface;
$generated = $invitationManager->invite(
organization: $org,
email: '[email protected]',
role: $memberRole,
invitedBy: $currentUser // optional, for audit trail
);
// $generated->plaintext — build accept URL and email it
// $generated->invitation — the persisted entity
Emits InvitationCreated with the plaintext token. Subscribe to send
the invitation email.
Accept an invitation
// GET /invitations/accept?token=abc123...
$membership = $invitationManager->accept($token, $authenticatedUser);
Validates:
- Token is known and active (not expired, not revoked, not accepted).
$authenticatedUser->getEmail()matches$invitation->getEmail()(case-insensitive). This prevents session hijacking — someone logged in with a different email cannot consume another person’s invitation.
On success: marks the invitation as accepted, creates the membership via
OrganizationManager::addMember(), emits InvitationAccepted.
Revoke an invitation
$invitationManager->revoke($invitation);
Only active invitations can be revoked. Emits InvitationRevoked.
Purge expired invitations
$invitationManager->purgeExpired();
Deletes pending-then-expired invitations. Accepted and revoked invitations are kept as audit trail.
Organization context
For request-scoped “which org am I operating in?” resolution:
use Derafu\IdentityBundle\Contract\Service\OrganizationContextInterface;
// In a kernel.request listener or middleware:
$orgId = $request->headers->get('X-Organization-Id');
$org = $orgRepository->find($orgId);
$organizationContext->setCurrent($org);
// Anywhere downstream:
$org = $organizationContext->getCurrent();
The default implementation (InMemoryOrganizationContext) is a stateful
singleton. In PHP-FPM this works naturally (container rebuilt per
request). For long-running workers (Swoole, RoadRunner), reset the
context between requests.
TTL configuration
derafu_identity:
invitation_ttl: 604800 # 7 days (default)