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:

  1. Token is known and active (not expired, not revoked, not accepted).
  2. $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)
On this page

Last updated on 16/04/2026 by Anonymous