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
contactsfield must reference exactly 2 contacts (cardinality is 2) - The relationship will fail validation if both contacts are the same entity
- The
typefield 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:
- Self-relationship: Both contacts are the same
- Contact type mismatch: Contacts don't match required types
- Missing required fields: Status, contacts field not set
- 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);
}