Relationship map
One file says which rows depend on which, and what happens to them when the row they point at is deleted. Our databases carry almost no real foreign keys, so this file is the referential integrity — the deleter, the checks and the map-driven joins all read it.
Where it lives and what reads it
| Piece | Where | What it does |
|---|---|---|
| the map | config/relationship_map.php | plain data: tables, the edges pointing at them, and a rule per edge. You write it |
| package maps | registered by a package's service provider | a package such as vsd/xauth-engine contributes the map for its own tables. The project's file wins where both name a table |
| the deleter | Vsd\Vsloquent\Support\CascadeDeleter | plan() answers "what would go"; delete() does it, in one transaction, or refuses and deletes nothing |
| the checker | php artisan vsloquent:map | measures the map against the live schema and reports what disagrees. Read-only — it never changes the map or the data |
| the read side | VSQuery::from(…)->joinOne(…) | joins by edge name; the table, the ON clause and LEFT vs INNER all come from the map |
The v2 shape
A map is written from the side that gets pointed at: each table lists who
references it, under referenced_by. That is the
direction a delete travels, so the danger sits next to the declaration. Each child
table holds a list of edges, because one table can point at the same parent
through two columns.
<?php
return [
'version' => 2,
'tables' => [
'employees' => [
'key' => 'id',
'referenced_by' => [
// A document is part of the employee: without them it means nothing.
'employee_documents' => [
['foreign_key' => 'employee_id', 'rel_type' => 'own', 'on_delete' => 'cascade',
'has' => 'documents', 'belongs_to' => 'employee'],
],
// A payslip records money that was paid. It outlives the employee.
'payslips' => [
['foreign_key' => 'employee_id', 'rel_type' => 'consume', 'on_delete' => 'restrict',
'has' => 'payslips', 'belongs_to' => 'employee'],
],
// A department survives losing its manager; someone is appointed later.
// Two edges from one table, so each needs its own `has` name.
'departments' => [
['foreign_key' => 'manager_id', 'rel_type' => 'consume', 'on_delete' => 'set_null',
'has' => 'managed_departments', 'belongs_to' => 'manager'],
['foreign_key' => 'deputy_id', 'rel_type' => 'consume', 'on_delete' => 'set_null',
'has' => 'deputised_departments', 'belongs_to' => 'deputy'],
],
// A link table: nothing but keys. It is `own` from BOTH ends.
'project_members' => [
['foreign_key' => 'employee_id', 'rel_type' => 'own', 'on_delete' => 'cascade',
'has' => 'memberships', 'belongs_to' => 'employee'],
],
],
],
'projects' => [
'referenced_by' => [
'project_members' => [
['foreign_key' => 'project_id', 'rel_type' => 'own', 'on_delete' => 'cascade',
'has' => 'members', 'belongs_to' => 'project'],
],
],
],
],
];
Per-table keys
| Key | Default | Means |
|---|---|---|
key | id | the column the edges point at |
binary_key | false | the key is binary (bytea / BINARY); ids travel as hex |
connection | null = the default | the database connection the table lives on, so a caller that knows only the table name does not have to know where it is |
referenced_by | — | child table => [edge, edge, …] |
Per-edge keys
| Key | Required | Means |
|---|---|---|
foreign_key | yes | the column on the child table |
on_delete | yes | what the deleter does to child rows: cascade | set_null | restrict | ignore (below) |
rel_type | yes | what the child row is to the parent: own | consume (below) |
has | when a child table has more than one edge to this parent | the name read from the parent: $employee->documents |
belongs_to | to use the read side | the name read from the child: $document->employee |
references_column | no — defaults to the parent's key | for an edge that points at some other unique column |
note | when the declaration is an exception | why. Required in practice on every ignore and every consume + cascade |
when | reserved | a discriminator for polymorphic keys. Not supported — the checker refuses it until the deleter can honour it |
role and inverse are older names for has and
belongs_to and are still read. Cardinality is never declared: the table
holding the foreign key is always the many side, and many-to-many is a link
table owned from both ends. The reverse direction (what does a payslip point at?) is
computed at load — never write both directions by hand.
Older maps keep working. A file without
'version' => 2 is read as v1 —
parent => [child => ['foreign_key' => …, 'on_delete' => …]],
one edge per pair, no rel_type.
php artisan vsloquent:map --upgrade prints the v2 version of it for you
to review and paste. It leaves every rel_type as
null // DECIDE: the tool converts the shape, never the judgement.
on_delete — what happens
This is the only field the deleter acts on. It is your decision about the domain, and no tool fills it in for you.
| Mode | Child rows when the parent goes | Typical case |
|---|---|---|
cascade | deleted too, and their own children after them | parts of the parent: documents, invoice lines, link rows |
set_null | kept; the foreign key is cleared | a pointer that someone will re-point: a department's manager |
restrict | the delete is refused while any exist, at any depth, and the refusal names them | records of something that happened: payslips, receipts, settlements |
ignore | left alone, pointing at nothing | audit trails and snapshots, where an orphaned pointer is accepted. Needs a note |
An edge with no on_delete is undecided, and the deleter
refuses any delete that reaches it. That is why the scaffold writes
'on_delete' => null, // DECIDE rather than leaving the key out: a missing
key reads as "not applicable", an explicit null reads as "nobody has
answered this yet".
Only cascade carries a delete further down. set_null and
restrict stop it at that edge. If any restrict is hit
anywhere in the tree, nothing at all is deleted.
rel_type — own or consume
rel_type answers one question about the child row:
Does it still mean anything if the row it points at disappears?
| Answer | rel_type | Examples |
|---|---|---|
| No — it is part of the parent | own | an employee's documents; an invoice's lines; a link row in project_members |
| Yes — it is its own thing that uses the parent | consume | a department that has a manager; a payslip for an employee; a class that has a teacher |
It belongs to the edge, not the table. One row can be both: an assignment is
own from its class (no class, no assignment) and consume
from its teacher (a new teacher takes it over).
own is transitive — a part of a part is still a part.
consume stops where it is found: if a payslip consumes an employee and
the employee consumes a department, the payslip has nothing to do with the department
when something is deleted.
When it comes into play
rel_type never deletes, keeps or blocks a single row.
The deleter does not read it — a package test fails if it ever does. A
wrong rel_type costs one misleading line in a report; it can never
cost data.
Its job is to make your on_delete checkable. On its own, any
on_delete looks as plausible as any other. Paired with what the edge
is, half the combinations contradict themselves, and the checker can say so.
It comes into play in three places:
vsloquent:map --verifycompares each edge's pair (the table below) and walks the graph for problems that reach several edges.- Delete previews attach warnings to a finished
plan()— rows that also belong to a parent you are not deleting, settlement pointers about to be cleared. They are added to the plan from outside it, so they cannot change what gets deleted. - The scaffold, which marks a table that is nothing but keys as
own, because a link table means nothing without either end. Everywhere else it leavesrel_typefor you.
The pairs
| rel_type | on_delete | Verdict |
|---|---|---|
own | cascade | Agrees — the parts go with the whole |
own | restrict | Agrees — deliberate friction: "clear the children first" |
own | set_null | Disagrees — an owned row with no owner is an orphan |
own | ignore | Disagrees — declared part of the parent, then left unmanaged |
consume | restrict | Agrees — the closed case: it records something that happened |
consume | set_null | Agrees — the open case: it will be re-pointed |
consume | ignore | Agrees — an orphaned pointer is acceptable, and the map says so |
consume | cascade | Questionable — destroys an independent fact as a side effect. Right only for derived rows; say why in note |
A disagreement never says which half is wrong — the label may be careless and the
on_delete exactly right. It says one of the two is wrong, and you
decide which.
Tenancy columns are consume + restrict, never
own. subs_id and branch_id look
like ownership, but declared that way one branch delete would cascade through most
of the database. A subscriber or branch is a scope, not a parent. Removing one for
good is a purpose-built purge, not a generic cascade.
Checking the map
php artisan vsloquent:map # coverage numbers, then every finding
php artisan vsloquent:map --verify # findings only
php artisan vsloquent:map --coverage # headline numbers only
php artisan vsloquent:map --scaffold # PHP for relationships nobody declared, judgement left UNSET
php artisan vsloquent:map --upgrade # the v1 map rewritten as v2
php artisan vsloquent:map --verify --parent=employees --connection=pgsql
Findings come in two kinds. Blocking ones mean a delete that reaches that edge will fail. The others mean the delete will succeed — and you may not want it to.
Blocking — the deleter refuses
| Finding | Means |
|---|---|
missing-parent / missing-table | a table the map names does not exist on that connection |
no-foreign-key / missing-column | the edge names no column, or a column the child table does not have |
no-on-delete | undecided — answer it |
bad-on-delete / bad-rel-type | a value outside the four modes or the two types |
when-unsupported | the edge declares when; remove the edge or the discriminator |
Warnings — the delete would succeed
| Finding | Means |
|---|---|
disagrees | own with set_null or ignore — one of the two is wrong |
questionable | consume with cascade — fine for derived rows, and the note should say so |
no-note | an ignore with no reason recorded |
no-name | several edges from one child table, and they need has names to tell them apart |
set-null-not-nullable | set_null on a NOT NULL column — the delete would fail |
looks-polymorphic | the foreign key sits beside a *_type column, so it points at more than one table and the edge reaches the others' rows |
reference-not-unique | the column the edge points at has no unique index, so joinOne() may match more than one row |
collateral-reach | a cascade travels down and lands on a consume edge it will clear or destroy |
co-owned-cascade | deleting one parent removes rows that another parent also owns |
link-table-half-declared | a keys-only table has one end declared and not the other, so deleting the other end leaves link rows pointing at nothing |
own-cycle | cascades loop back on themselves — always a mistake |
cross-connection | a cascade would cross databases, and no transaction covers both |
A delete preview adds two more for the specific rows involved:
co-owned ("12 project_members rows also belong to projects, which
is not being deleted") and consume-nulled (pointers about to be
cleared on rows that record something that happened).
Deleting through the map
use Vsd\Vsloquent\Support\CascadeDeleter;
// Dry run: ['delete' => …, 'nullify' => …, 'blocked' => …, 'missing' => …]
$plan = CascadeDeleter::plan('employees', $id);
// The real thing. One transaction; a `restrict` anywhere and nothing is deleted.
$affected = CascadeDeleter::delete('employees', $id); // [table => rows affected]
plan() and delete() walk the same code, so a preview cannot
disagree with what the delete does. If you already opened a transaction,
delete() joins it. Pass a soft-delete column as the fourth argument and the
whole cascade soft-deletes instead.
Other backends
Today the map and everything that reads it are PHP — vsd/vsloquent, in
Laravel. Nothing in the idea depends on PHP. The map is plain data, and the rules
above are graph logic over that data. A NestJS, plain Node or Python back end can take
part in the same contract. None of those ports exists yet; a port has
to keep these, or the backends will disagree about the same database:
- One map, read as data. The same tables, edges and keys as above, not a re-typed copy per language. Two copies drift, and both look plausible.
- The map wins over the ORM. Prisma, TypeORM and SQLAlchemy have their own
onDelete, and SQLAlchemy'sdelete-orphanis close toown. None of them knowsconsume, the pairs or the warnings. Use the ORM for queries, not as the source of the delete rules. - The same deleter behaviour: one transaction; a
restrictat any depth refuses the whole delete; onlycascadegoes deeper; an undecided edge refuses;plan()anddelete()share one walk. - The same wall: the deleter never reads
rel_type. Warnings are attached to a finished plan, never fed back into it. - The same findings, with the same names, so a report from one stack reads the same as one from another.
This follows the rest of the framework: same contract, any stack, through twins of the PHP packages rather than a second set of rules. See Next.js & Node for the twins that exist today.