Associations
Link any two CRM records, define association types with labels and cardinality, and query associated records.
An association is a directed link from a parent record to a child record, stored in odden_associations. Any two Eloquent models can be linked: contacts, companies, custom object records, or other modules' models such as deals. Contacts, companies, and custom object records get helper methods from the HasAssociations trait.
Linking records
$association = $contact->associateWith($company, 'employee');
$contact->isAssociatedWith($company); // true
$company->isAssociatedWith($contact, 'employee'); // true
$contact->getAssociated(Company::class); // Collection of companies
$company->getAssociated(Contact::class, 'employee'); // only "employee" links
$contact->dissociateFrom($company); // number of rows deletedHasAssociations methods:
| Method | Behavior |
|---|---|
associateWith(Model $record, string|AssociationType $type = 'default', ?string $label = null): Association |
Calls AssociateRecordsAction with $this as the parent. |
dissociateFrom(Model $record, ?string $type = null): int |
Deletes links in either direction, optionally only of one type. |
isAssociatedWith(Model $record, ?string $type = null): bool |
Checks both directions. |
getAssociated(string $modelClass, ?string $type = null): Collection |
Records of $modelClass linked in either direction. |
getAssociatedByLabel(string $modelClass, string $label): Collection |
Records linked with that label as seen from $this: children whose link or type has that label, and parents whose type has that reverse_label. A link whose type has no reverse label reads the same from both sides. |
associationsAsParent(), associationsAsChild() |
MorphMany relations to the raw Association rows. |
The type column holds a type name, with default as the default. A link is unique per parent, child, and type, so associateWith() with the same arguments returns the existing row. The same two records can be linked under different types.
Direction matters for some queries. Contact::companies() and Company::contacts() only see links where the contact is the parent and the company the child. That's the direction associateWith() uses when you call it on the contact, and the one domain auto-association uses. The trait methods above check both directions.
You can also call the action directly:
use Odden\Core\Actions\AssociateRecordsAction;
app(AssociateRecordsAction::class)->execute($company, $contact, 'billing_contact');AssociateRecordsAction::execute(Model $parent, Model $child, string|AssociationType $type = 'default', ?string $label = null): Association:
- Resolves the association type. If you pass a string, it looks for an
AssociationTypewith thatnamein the parent record's team, then for one with no team (and, when a tenancy add-on is installed, only in the parent's tenant or with no tenant). Another team's or tenant's type is never used. If none is found, the string is still stored astypeand no rules apply. - Enforces the type's cardinality, throwing
Odden\Core\Exceptions\CardinalityViolationException. The check and the write run in a transaction holding a lock on the type, so two concurrent links can't both pass. - Creates the link, or updates the existing one. It sets
association_type_idand thelabel, which defaults to the type'slabel. - Dispatches
RecordsAssociated, only when a new row was created.
Merging records moves their associations to the surviving record. See Duplicates and merging.
Association types
Odden\Core\Models\AssociationType (table odden_association_types) gives a type name a label, an optional reverse label, and a cardinality rule.
| Column | Notes |
|---|---|
name |
Unique per team (and tenant). Rows with no team_id are shared by every team. Creating a duplicate throws Odden\Core\Exceptions\DuplicateNameException. This is what associations.type stores. |
label |
Label shown from the parent's side. |
reverse_label |
Label shown from the child's side. Nullable. |
cardinality |
AssociationCardinality, default many_to_many. |
from_record_type, to_record_type |
Morph classes. getLabelFor() uses them, and AssociateRecordsAction enforces them (see below). |
is_system |
Boolean, default false. A system type can't be deleted, and its name, from_record_type and to_record_type can't change (labels and cardinality can); both throw SystemAssociationTypeException. |
team_id |
Nullable. |
use Odden\Core\Actions\AssociateRecordsAction;
use Odden\Core\Actions\CreateAssociationTypeAction;
use Odden\Core\Models\Company;
use Odden\Core\Models\Contact;
$type = app(CreateAssociationTypeAction::class)->execute([
'name' => 'billing_contact',
'label' => 'Billing contact',
'reverse_label' => 'Billing contact for',
'cardinality' => 'one_to_one',
'from_record_type' => (new Company)->getMorphClass(),
'to_record_type' => (new Contact)->getMorphClass(),
]);
$association = app(AssociateRecordsAction::class)->execute($company, $jane, $type);
$association->label; // "Billing contact"
$type->getLabelFor($jane); // "Billing contact for"
$company->getAssociatedByLabel(Contact::class, 'Billing contact');
$jane->getAssociatedByLabel(Company::class, 'Billing contact for');
$company->associateWith($sam, 'billing_contact'); // throws CardinalityViolationExceptionCreateAssociationTypeAction::execute(array $attributes): AssociationType converts a string cardinality to the enum and creates the row. AssociationType::getLabelFor(Model $record, ?Association $association = null) returns the label $record sees: reverse_label when the record is the child, label otherwise, and always label when there's no reverse label. Without an association it decides by record type (child when the record's morph class equals to_record_type), so pass the association when both ends can be the same type, such as company to company.
When from_record_type and to_record_type are both set, the two records must be of those types, in either order. When only one is set, one of the two records must be of that type. Otherwise AssociateRecordsAction (and so associateWith()) throws Odden\Core\Exceptions\InvalidAssociationException and nothing is saved. With neither set, any pair of models can be linked.
Cardinality
Odden\Core\Enums\AssociationCardinality:
| Case | Value | Rule checked by AssociateRecordsAction |
|---|---|---|
ManyToMany |
many_to_many |
None. |
OneToMany |
one_to_many |
A child can have at most one parent of this type. |
OneToOne |
one_to_one |
A parent can have at most one child, and a child at most one parent, of this type. |
Rules apply only to links created with a type that exists as an AssociationType row. Re-linking the same parent and child never violates them.
When from_record_type and to_record_type are both set and differ, a record's type decides its side, whichever way round you link. With a one-to-many employer type from companies to contacts, $contact->associateWith($otherCompany, 'employer') is refused just like $otherCompany->associateWith($contact, 'employer'). Without both record types, the parent is the record you call associateWith() on.
Free-form labels
Without a type, you can still label a link:
$association = $jane->associateWith($sam, 'referral', 'Referred by');
$association->label; // "Referred by"
$association->association_type_id; // nullThe Association model
Odden\Core\Models\Association has parent() and child() morph relations, and associationType(), which belongs to AssociationType. Query it directly when you need the raw links:
use Odden\Core\Models\Association;
$links = Association::query()
->where('parent_type', $contact->getMorphClass())
->where('parent_id', $contact->getKey())
->with('child')
->get();