Understanding attachRelation() in MonkeysLegion
Posted by admin – July 21, 2025

A deep dive into how the framework links many-to-many entities at runtime
1 Why do we need attachRelation()
In a many-to-many relationship—users ↔ companies, posts ↔ tags, etc.—neither side stores the foreign-key of the other. Instead, an intermediate join table (company_user) contains:
company_id | user_id |
---|---|
… | … |
attachRelation() is MonkeysLegion’s convenience helper that inserts one row into that join table, abstracting away reflection, metadata parsing, and SQL generation so your service layer can do something as simple as:
$companyRepo->attachRelation($company, 'users', $userId); // ← 1 line
2 The signature
public function attachRelation(
object $entity, // the owning entity instance
string $relationProp, // property name marked #[ManyToMany]
int|string $value // ID of the related record
): int // affected-row count
3 How it works—step-by-step
Step | What happens under the hood |
---|---|
1 | Reflection: The repository inspects $entity::$relationProp via ReflectionClass to find its #[ManyToMany] attribute. |
2 | Owning vs. inverse side detection: • If the property carries a joinTable, that side is owning.• If not, the code follows mappedBy or inversedBy to the other entity’s property and harvests its joinTable, flipping the column order so the current entity is always the “own” side. |
3 | ID extraction: Using ReflectionProperty('id'), it pulls the database ID of $entity. If the entity hasn’t been persisted yet, an InvalidArgumentException is thrown. |
4 | SQL builder: The framework’s fluent QueryBuilder inserts one row into the join table: INSERT INTO company_user (company_id, user_id) VALUES (:ownId, :value) |
5 | The method returns the affected-row count (usually 1). |
4 Owning-side & column swapping logic
// pseudo-code excerpt
if ($owningSide) {
$ownCol = $joinTable->joinColumn; // FK → THIS entity
$invCol = $joinTable->inverseColumn; // FK → related entity
} else {
// we borrowed the joinTable from the other side → swap
$ownCol = $joinTable->inverseColumn;
$invCol = $joinTable->joinColumn;
}
This means you may declare joinTable on either entity; the repository adjusts automatically.
// User $alice (id=34) joins Company $acme (id=7)
$acme = $companyRepo->find(7);
$rows = $companyRepo->attachRelation($acme, 'users', 34);
// $rows ➜ 1
/*
┌────────────┬─────────┐
│ company_id │ user_id │
├────────────┼─────────┤
│ 7 │ 34 │ ← new row inserted
└────────────┴─────────┘
*/
6 Error handling & edge-cases
Scenario | Exception / Result |
---|---|
$entity has no id yet | InvalidArgumentException: Entity must have an ID before attaching relations |
$relationProp not found | InvalidArgumentException: Property … not found |
Property isn’t #[ManyToMany] | InvalidArgumentException: … is not a ManyToMany relation |
Neither side defines a joinTable | InvalidArgumentException: Neither … carry joinTable metadata |
All errors fail fast—your service layer can catch and report them cleanly.
7 Best-practice tips
Persist first, relate second – Make sure the owning entity has an ID.
Single source of truth – Declare joinTable on only one side (usually the more
natural “owner”) to keep reflection fast.
Use descriptive join-table names – e.g. company_user, post_tag, mirroring Laravel and
Doctrine conventions.
Wrap in a transaction when you attach/detach multiple relations in one service method,
ensuring atomicity.
8 Detach works symmetrically
detachRelation() calls the same getJoinTableMeta() helper, swaps columns if needed, then executes:
DELETE FROM company_user
WHERE company_id = :ownId
AND user_id = :value
So your removal code stays just as concise:
$companyRepo->detachRelation($acme, 'users', 34);
TL;DR
attachRelation() is MonkeysLegion’s reflection-powered, side-agnostic helper that:
Finds or infers the join table & columns
Pulls the owning entity’s ID
Inserts one row via the fluent QueryBuilder
Let's you ignore which side declared joinTable
One line in your service code; the framework handles all the heavy lifting.