Skip to content

CRM Relationship Entity

The CRM relationship entity represents connections between two contacts in the system. It supports complex relationship modeling with features like asymmetric relationships, time-based relationships, and automatic validation.

Features

  • Revisionable: Full revision tracking with log messages and timestamps
  • Publishable: Relationships can be enabled/disabled
  • Time-bound: Relationships can have start and end dates
  • Typed: Configurable relationship types with bundles
  • Validated: Prevents self-relationships and validates contact references
  • Computed Fields: Automatic contact_a and contact_b field computation
  • Age Calculation: Automatic age calculation based on relationship duration

Entity Structure

Base Fields

Field Type Description Revisionable
id Integer Primary key No
uuid UUID Universal identifier No
revision_id Integer Current revision ID No
bundle String Relationship type machine name No
status Boolean Published/unpublished status Yes
contacts Entity Reference References exactly 2 contacts (cardinality: 2) Yes
contact_a Computed First contact in relationship No
contact_b Computed Second contact in relationship No
start_date DateTime When relationship started Yes
end_date DateTime When relationship ended Yes
age Computed Integer Age of relationship in configurable units No
created Timestamp Creation time Yes
changed Timestamp Last modification time Yes

Revision Fields

Field Type Description
revision_uid Entity Reference User who created revision
revision_timestamp Timestamp When revision was created
revision_log Text Revision log message

Relationship Types

Relationship types are configuration entities that define the nature of relationships between contacts. They support both symmetric and asymmetric relationships.

Type Properties

Property Type Description
id String Machine name
label String Human-readable name
description Text Description of relationship type
asymmetric Boolean Whether relationship roles are different
contact_type_a String Required contact type for first contact
contact_type_b String Required contact type for second contact
label_a String Label for first contact's role
label_b String Label for second contact's role

Default Relationship Types

The system includes several pre-configured relationship types:

Symmetric Relationships (asymmetric: false)

  • Spouse: Person-to-person spousal relationship
  • Sibling: Person-to-person sibling relationship
  • Partner: Person-to-person partnership
  • Parent: Person-to-person parent-child relationship

Asymmetric Relationships (asymmetric: true)

  • Employee: Person (employee) to Organization (employer)
  • Volunteer: Person (volunteer) to Organization
  • Member: Person (member) to Organization
  • Head of Household: Person (head) to Household
  • Household Member: Person (member) to Household
  • Supervised: Person (supervisee) to Person (supervisor)

Technical Implementation

Entity Class

class Relationship extends RevisionableContentEntityBase implements RelationshipInterface

The entity uses several traits: - EntityChangedTrait: Automatic change tracking - EntityPublishedTrait: Published/unpublished functionality - RevisionLogEntityTrait: Revision logging capabilities

Computed Fields

The contact_a and contact_b fields are computed from the base contacts field using the RelationshipContactsItemList class. This allows:

  • Form Display: Separate autocomplete widgets for each contact
  • View Display: Individual display of each contact with role labels
  • API Access: Direct access to contacts by position

Validation

The entity includes the RelationshipContacts constraint that prevents creating relationships where both contacts are the same entity.

Custom Label Generation

Relationships automatically generate descriptive labels in the format:

{RelationshipType} ({ContactA} <=> {ContactB})

For example: "Spouse (John Doe <=> Jane Doe)"

Database Schema

erDiagram
    crm_relationship {
      int id PK
      uuid uuid UK
      int revision_id FK
      string type
      boolean status
      datetime start_date
      datetime end_date
      timestamp created
      timestamp changed
    }

    crm_relationship_revision {
      int revision_id PK
      int id FK
      uuid uuid
      string type
      boolean status
      datetime start_date
      datetime end_date
      timestamp created
      timestamp changed
      int revision_uid FK
      timestamp revision_timestamp
      text revision_log
    }

    crm_relationship__contacts {
      int entity_id FK
      int revision_id FK
      int delta
      int target_id FK
    }

    crm_relationship_type {
      string id PK
      string label
      text description
      boolean asymmetric
      string contact_type_a
      string contact_type_b
      string label_a
      string label_b
    }

    crm_contact {
      int id PK
      string name
    }

    crm_relationship ||--o{ crm_relationship_revision : revisions
    crm_relationship ||--|| crm_relationship_type : bundle
    crm_relationship ||--o{ crm_relationship__contacts : contacts
    crm_relationship__contacts }o--|| crm_contact : references

Usage Examples

Creating a Relationship Programmatically

<?php

declare(strict_types=1);

use Drupal\crm\Entity\Relationship;

// Create a new relationship between two contacts.
$relationship = Relationship::create([
  'type' => 'spouse',
  'contacts' => [
    ['target_id' => $contact_a_id],
    ['target_id' => $contact_b_id],
  ],
  'start_date' => '2020-01-01',
  'status' => TRUE,
]);

try {
  $relationship->save();
  \Drupal::logger('my_module')->info('Created relationship with ID @id', ['@id' => $relationship->id()]);
}
catch (\Exception $e) {
  \Drupal::logger('my_module')->error('Failed to create relationship: @message', ['@message' => $e->getMessage()]);
}

Important Notes:

  • The contacts field must reference exactly 2 contacts (cardinality is 2)
  • The relationship will fail validation if both contacts are the same entity
  • The type field must reference an existing relationship type configuration entity
  • Start and end dates are optional but useful for tracking relationship duration

Accessing Computed Fields

<?php

declare(strict_types=1);

use Drupal\crm\Entity\Relationship;

// Load the relationship entity.
$relationship = Relationship::load($relationship_id);

if ($relationship === NULL) {
  \Drupal::logger('my_module')->warning('Relationship @id not found.', ['@id' => $relationship_id]);
  return;
}

// Get contacts via computed fields.
$contact_a = $relationship->get('contact_a')->entity;
$contact_b = $relationship->get('contact_b')->entity;

// Always check that referenced entities exist.
if ($contact_a === NULL || $contact_b === NULL) {
  \Drupal::logger('my_module')->error('Relationship @id has invalid contact references.', ['@id' => $relationship_id]);
  return;
}

\Drupal::logger('my_module')->info('Relationship: @name_a <=> @name_b', [
  '@name_a' => $contact_a->label(),
  '@name_b' => $contact_b->label(),
]);

// Get relationship age (computed field).
$age_value = $relationship->get('age')->value;

if ($age_value !== NULL) {
  \Drupal::logger('my_module')->info('Relationship age: @age years', ['@age' => $age_value]);
}

Working with Relationship Types

<?php

declare(strict_types=1);

// Load a relationship type configuration entity.
$storage = \Drupal::entityTypeManager()->getStorage('crm_relationship_type');
$type = $storage->load('spouse');

if ($type === NULL) {
  \Drupal::logger('my_module')->warning('Relationship type "spouse" not found.');
  return;
}

// Check if the relationship is asymmetric.
$is_asymmetric = $type->get('asymmetric');

// Get role labels for each contact position.
$label_a = $type->get('label_a'); // "Spouse"
$label_b = $type->get('label_b'); // "Spouse"

// Get contact type restrictions.
$contact_type_a = $type->get('contact_type_a'); // Required contact type for position A
$contact_type_b = $type->get('contact_type_b'); // Required contact type for position B

\Drupal::logger('my_module')->info('Relationship type: @label (@label_a <=> @label_b)', [
  '@label' => $type->label(),
  '@label_a' => $label_a,
  '@label_b' => $label_b,
]);

Understanding Asymmetric Relationships:

  • Symmetric (asymmetric: false): Both contacts have the same role (e.g., "Spouse", "Sibling")
  • Asymmetric (asymmetric: true): Contacts have different roles (e.g., "Employee" <=> "Employer", "Parent" <=> "Child")

Access Control

Relationships use the RelationshipAccessControlHandler which provides: - Entity-level access control - Type-based permissions - Administrative override with 'administer crm' permission

Checking Access Programmatically

<?php

declare(strict_types=1);

use Drupal\crm\Entity\Relationship;
use Drupal\Core\Session\AccountInterface;

/**
 * Check if a user has access to a relationship.
 *
 * @param int $relationship_id
 *   The relationship entity ID.
 * @param string $operation
 *   The operation to check (view, update, delete).
 * @param \Drupal\Core\Session\AccountInterface|null $account
 *   The user account (NULL for current user).
 *
 * @return bool
 *   TRUE if access is granted, FALSE otherwise.
 */
function check_relationship_access(int $relationship_id, string $operation, ?AccountInterface $account = NULL): bool {
  if ($account === NULL) {
    $account = \Drupal::currentUser();
  }

  $relationship = Relationship::load($relationship_id);

  if ($relationship === NULL) {
    return FALSE;
  }

  return $relationship->access($operation, $account);
}

Common Pitfalls and Best Practices

Preventing Self-Relationships

The system includes validation to prevent a contact from having a relationship with itself:

<?php

declare(strict_types=1);

use Drupal\crm\Entity\Relationship;

// WRONG: This will fail validation.
$relationship = Relationship::create([
  'type' => 'spouse',
  'contacts' => [
    ['target_id' => $contact_id],
    ['target_id' => $contact_id], // Same contact!
  ],
]);
$relationship->save(); // Throws validation exception!

// CORRECT: Use two different contacts.
$relationship = Relationship::create([
  'type' => 'spouse',
  'contacts' => [
    ['target_id' => $contact_a_id],
    ['target_id' => $contact_b_id],
  ],
]);

Respecting Contact Type Restrictions

Relationship types can restrict which contact types are allowed:

<?php

declare(strict_types=1);

use Drupal\crm\Entity\Relationship;
use Drupal\crm\Entity\Contact;

// Load the relationship type to check restrictions.
$type_storage = \Drupal::entityTypeManager()->getStorage('crm_relationship_type');
$rel_type = $type_storage->load('employee');

if ($rel_type === NULL) {
  return;
}

$required_type_a = $rel_type->get('contact_type_a'); // e.g., 'person'
$required_type_b = $rel_type->get('contact_type_b'); // e.g., 'organization'

// Verify contacts match required types.
$contact_a = Contact::load($contact_a_id);
$contact_b = Contact::load($contact_b_id);

if ($contact_a === NULL || $contact_b === NULL) {
  \Drupal::logger('my_module')->error('One or both contacts not found.');
  return;
}

if ($contact_a->bundle() !== $required_type_a) {
  \Drupal::logger('my_module')->error('Contact A must be of type @type', [
    '@type' => $required_type_a,
  ]);
  return;
}

if ($contact_b->bundle() !== $required_type_b) {
  \Drupal::logger('my_module')->error('Contact B must be of type @type', [
    '@type' => $required_type_b,
  ]);
  return;
}

// Now safe to create the relationship.
$relationship = Relationship::create([
  'type' => 'employee',
  'contacts' => [
    ['target_id' => $contact_a_id],
    ['target_id' => $contact_b_id],
  ],
  'status' => TRUE,
]);

Working with Computed Contact Fields

The contact_a and contact_b fields are computed from the contacts field:

<?php

declare(strict_types=1);

use Drupal\crm\Entity\Relationship;

// WRONG: Trying to set computed fields directly.
$relationship = Relationship::create([
  'type' => 'spouse',
  'contact_a' => ['target_id' => $contact_a_id], // This won't work!
  'contact_b' => ['target_id' => $contact_b_id], // This won't work!
]);

// CORRECT: Set the base 'contacts' field.
$relationship = Relationship::create([
  'type' => 'spouse',
  'contacts' => [
    ['target_id' => $contact_a_id],
    ['target_id' => $contact_b_id],
  ],
]);

// Then access via computed fields.
$contact_a = $relationship->get('contact_a')->entity;
$contact_b = $relationship->get('contact_b')->entity;

Understanding Asymmetric Relationships

For asymmetric relationships, the order of contacts matters:

<?php

declare(strict_types=1);

use Drupal\crm\Entity\Relationship;

// For an asymmetric "Employee" relationship:
// - contact_a should be the employee (person)
// - contact_b should be the employer (organization)

$relationship = Relationship::create([
  'type' => 'employee',
  'contacts' => [
    ['target_id' => $person_id],       // Position A: Employee
    ['target_id' => $organization_id], // Position B: Employer
  ],
]);

// The display will show:
// "Employee (Person Name <=> Organization Name)"
// Where Person Name has the role from label_a ("Employee")
// And Organization Name has the role from label_b ("Employer")

Handling Revisions Properly

<?php

declare(strict_types=1);

use Drupal\crm\Entity\Relationship;

$relationship = Relationship::load($relationship_id);

if ($relationship === NULL) {
  return;
}

// Update the relationship with a new revision.
$relationship->set('end_date', date('Y-m-d'));
$relationship->setNewRevision(TRUE);
$relationship->setRevisionLogMessage('Relationship ended');

try {
  $relationship->save();
}
catch (\Exception $e) {
  \Drupal::logger('my_module')->error('Failed to update relationship: @message', [
    '@message' => $e->getMessage(),
  ]);
}

Troubleshooting Common Issues

Issue: Relationship Validation Fails

Problem: Getting validation errors when creating relationships.

Common Causes:

  1. Self-relationship: Both contacts are the same
  2. Contact type mismatch: Contacts don't match required types
  3. Missing required fields: Status, contacts field not set
  4. Invalid contact references: Referenced contacts don't exist

Solution:

<?php

declare(strict_types=1);

use Drupal\crm\Entity\Relationship;
use Drupal\crm\Entity\Contact;

// Validate before creating.
$contact_a = Contact::load($contact_a_id);
$contact_b = Contact::load($contact_b_id);

if ($contact_a === NULL || $contact_b === NULL) {
  throw new \InvalidArgumentException('Both contacts must exist.');
}

if ($contact_a_id === $contact_b_id) {
  throw new \InvalidArgumentException('Contacts cannot be the same.');
}

$relationship = Relationship::create([
  'type' => 'spouse',
  'contacts' => [
    ['target_id' => $contact_a_id],
    ['target_id' => $contact_b_id],
  ],
  'status' => TRUE,
]);

// Validate before saving.
$violations = $relationship->validate();
if ($violations->count() > 0) {
  foreach ($violations as $violation) {
    \Drupal::logger('my_module')->error('Validation error: @message', [
      '@message' => $violation->getMessage(),
    ]);
  }
  return;
}

$relationship->save();

Issue: Cannot Access Relationship Contacts

Problem: contact_a or contact_b returns NULL.

Solution: Ensure the contacts field has exactly 2 items and both referenced entities exist:

<?php

declare(strict_types=1);

$relationship = Relationship::load($relationship_id);

if ($relationship === NULL) {
  return;
}

$contacts = $relationship->get('contacts')->getValue();

if (count($contacts) !== 2) {
  \Drupal::logger('my_module')->error('Relationship @id has invalid contacts field.', [
    '@id' => $relationship_id,
  ]);
  return;
}

$contact_a = $relationship->get('contact_a')->entity;
$contact_b = $relationship->get('contact_b')->entity;

if ($contact_a === NULL || $contact_b === NULL) {
  \Drupal::logger('my_module')->error('Relationship @id has deleted contact references.', [
    '@id' => $relationship_id,
  ]);
  return;
}

Performance Considerations

Loading Relationships Efficiently

<?php

declare(strict_types=1);

use Drupal\crm\Entity\Relationship;

// INEFFICIENT: Loading one by one.
foreach ($relationship_ids as $id) {
  $relationship = Relationship::load($id);
  // Process...
}

// EFFICIENT: Bulk load.
$relationships = Relationship::loadMultiple($relationship_ids);
foreach ($relationships as $relationship) {
  // Process...
}

Querying Relationships

<?php

declare(strict_types=1);

// Find all relationships for a specific contact.
$query = \Drupal::entityQuery('crm_relationship')
  ->condition('contacts', $contact_id)
  ->condition('status', TRUE)
  ->accessCheck(TRUE);

$relationship_ids = $query->execute();

if (!empty($relationship_ids)) {
  $relationships = \Drupal\crm\Entity\Relationship::loadMultiple($relationship_ids);
}