VSA Monolith Framework

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

PieceWhereWhat it does
the mapconfig/relationship_map.phpplain data: tables, the edges pointing at them, and a rule per edge. You write it
package mapsregistered by a package's service providera package such as vsd/xauth-engine contributes the map for its own tables. The project's file wins where both name a table
the deleterVsd\Vsloquent\Support\CascadeDeleterplan() answers "what would go"; delete() does it, in one transaction, or refuses and deletes nothing
the checkerphp artisan vsloquent:mapmeasures the map against the live schema and reports what disagrees. Read-only — it never changes the map or the data
the read sideVSQuery::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.

config/relationship_map.php
<?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

KeyDefaultMeans
keyidthe column the edges point at
binary_keyfalsethe key is binary (bytea / BINARY); ids travel as hex
connectionnull = the defaultthe 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

KeyRequiredMeans
foreign_keyyesthe column on the child table
on_deleteyeswhat the deleter does to child rows: cascade | set_null | restrict | ignore (below)
rel_typeyeswhat the child row is to the parent: own | consume (below)
haswhen a child table has more than one edge to this parentthe name read from the parent: $employee->documents
belongs_toto use the read sidethe name read from the child: $document->employee
references_columnno — defaults to the parent's keyfor an edge that points at some other unique column
notewhen the declaration is an exceptionwhy. Required in practice on every ignore and every consume + cascade
whenreserveda 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.

ModeChild rows when the parent goesTypical case
cascadedeleted too, and their own children after themparts of the parent: documents, invoice lines, link rows
set_nullkept; the foreign key is cleareda pointer that someone will re-point: a department's manager
restrictthe delete is refused while any exist, at any depth, and the refusal names themrecords of something that happened: payslips, receipts, settlements
ignoreleft alone, pointing at nothingaudit 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?

Answerrel_typeExamples
No — it is part of the parentownan employee's documents; an invoice's lines; a link row in project_members
Yes — it is its own thing that uses the parentconsumea 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:

  1. vsloquent:map --verify compares each edge's pair (the table below) and walks the graph for problems that reach several edges.
  2. 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.
  3. 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 leaves rel_type for you.

The pairs

rel_typeon_deleteVerdict
owncascadeAgrees — the parts go with the whole
ownrestrictAgrees — deliberate friction: "clear the children first"
ownset_nullDisagrees — an owned row with no owner is an orphan
ownignoreDisagrees — declared part of the parent, then left unmanaged
consumerestrictAgrees — the closed case: it records something that happened
consumeset_nullAgrees — the open case: it will be re-pointed
consumeignoreAgrees — an orphaned pointer is acceptable, and the map says so
consumecascadeQuestionable — 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

terminal
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

FindingMeans
missing-parent / missing-tablea table the map names does not exist on that connection
no-foreign-key / missing-columnthe edge names no column, or a column the child table does not have
no-on-deleteundecided — answer it
bad-on-delete / bad-rel-typea value outside the four modes or the two types
when-unsupportedthe edge declares when; remove the edge or the discriminator

Warnings — the delete would succeed

FindingMeans
disagreesown with set_null or ignore — one of the two is wrong
questionableconsume with cascade — fine for derived rows, and the note should say so
no-notean ignore with no reason recorded
no-nameseveral edges from one child table, and they need has names to tell them apart
set-null-not-nullableset_null on a NOT NULL column — the delete would fail
looks-polymorphicthe foreign key sits beside a *_type column, so it points at more than one table and the edge reaches the others' rows
reference-not-uniquethe column the edge points at has no unique index, so joinOne() may match more than one row
collateral-reacha cascade travels down and lands on a consume edge it will clear or destroy
co-owned-cascadedeleting one parent removes rows that another parent also owns
link-table-half-declareda keys-only table has one end declared and not the other, so deleting the other end leaves link rows pointing at nothing
own-cyclecascades loop back on themselves — always a mistake
cross-connectiona 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

PHP
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's delete-orphan is close to own. None of them knows consume, 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 restrict at any depth refuses the whole delete; only cascade goes deeper; an undecided edge refuses; plan() and delete() 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.