Deletion System Documentation
Overview
The deletion system manages cascading deletes, foreign key constraints, and referential integrity when records are permanently deleted from the database. It uses a child-centric, declarative approach where dependent models declare their own behavior when parent records are deleted.
> GET-is-read-only: soft_delete() and permanent_delete() (like save()) refuse to run on a GET request — a GET must not mutate data. A legitimate GET-action delete link must wrap the call in SystemBase::$allow_get_mutation = true; try { … } finally { SystemBase::$allow_get_mutation = false; }. See Logic Architecture — GET-is-read-only invariant.
Key Concepts
- Child-Centric: Child models declare how they should be handled when their parent is deleted (not the other way around)
- Auto-Detection: Foreign keys are automatically detected from column naming patterns (
xxx_yyy_entity_id) - Incremental Registration: Deletion rules are registered per-model without affecting other models' rules
- Declared, never guessed: every detected foreign key must carry a declared action; an undeclared relationship registers as
preventand refuses the referenced row's deletion, naming the model and column to declare - Separation of Concerns: Core and plugin deletion rules are managed independently
How It Works
1. Foreign Key Auto-Detection
The system detects foreign keys based on column naming, then looks up the real table — it never guesses a pluralized name:
Pattern: {prefix}_{source_prefix}_{...}_id
Examples:
- ord_usr_user_id → references usr_users table
- odi_pro_product_id → references pro_products table
- evt_loc_location_id → references loc_locations table
- aip_rcr_recipe_run_id → references rcr_recipe_runs table
- ieg_iea_inbound_email_alias_id → references iea_inbound_email_aliases tableFor a column on a model, the system:
- Strips the declaring model's own
{prefix}_ - Takes the first segment of what's left (e.g.
usr,pro,rcr,iea) - Looks that segment up in a registry of every loaded model's own
$prefixand real$tablename(core and every plugin, built once and cached) — the column only counts as a foreign key if the remainder also contains_idand the first segment is a real, registered model prefix
owner, a self-reference like parent, an external id like
stripe_customer) registers nothing — never a wrong guess. Give it an
explicit source_table (see below) if it does need to cascade.A prefix names one model. Every prefix is declared by exactly one model,
so pst_fil_file_id resolves to fil_files by its prefix alone. The
scaffolder warns rather than refuses when a new model takes a prefix another
already carries; if a pair is ever created on purpose, the column name's
embedded singular entity is matched against the candidates' table names and
only an exact singular/plural match resolves — a column matching none stays
unrecognized and its declaration must name source_table/source_class. A prefix with one owner resolves
by the prefix alone, whatever the entity part says.
Escape Hatch: Columns That Don't Fit the Convention
Some foreign keys don't have their target's prefix as the first segment after
stripping the owner's own prefix — a role-named column (rcp_owner_user_id),
a self-reference (agf_candidate_for → another row in the same table), or a
suffixed/non-standard name (mjb_created_by). Declare the real table
explicitly in $foreign_key_actions:
protected static $foreign_key_actions = [
'rcp_owner_user_id' => ['action' => 'cascade', 'source_table' => 'usr_users'],
'agf_candidate_for' => ['action' => 'cascade', 'source_table' => 'agf_agent_files'],
];source_class (a model class name) works the same way and is resolved to
that class's $tablename at registration time. A declared
$foreign_key_actions key that resolves neither by convention nor by an
explicit source_table/source_class produces a warning during
registration/sync rather than silently registering nothing — see
maintenance_scripts/dev_tools/validate_php_file.php's model-contract pass
for the same check at edit time.
2. Deletion Actions
Five actions are available:
| Action | Description | Use Case |
|---|---|---|
cascade | Delete dependent records via flat SQL | Logs, sessions, leaf data with no children |
permanent_delete | Load each record as a model and call its permanent_delete() | Records with custom deletion logic or their own child dependencies |
set_value | Set foreign key to specific value | Set to DELETED_USER sentinel value |
null | Set foreign key to NULL | Optional relationships |
prevent | Block deletion if dependents exist | Critical references that can't be orphaned |
Choosing an action
Ask one question first: *does the referenced row OWN this row, or is it
merely referenced by it? Both look identical in the schema — an integer
_id column — which is why the engine never guesses. An order owns its order
items (they should die with it); a phone number does not own the user who
references it (deleting the number must never delete the user).
| Relationship | Child's shape | Action |
|---|---|---|
| Owned | has children of its own, or a permanent_delete() override | permanent_delete |
| Owned | true leaf — no children, no custom cleanup | cascade |
| Mere reference | link is optional; row stays meaningful without it | null (nullable columns only) or set_value with a sentinel |
| Load-bearing reference | row is broken or dangerous without it (access gates, financial records) | prevent with a message |
cascadeon a child that has children strands the grandchildren.cascadeis a flat SQLDELETE— none of the child's own deletion rules run. If the target table appears as a source table anywhere indel_deletion_rules, the action must bepermanent_delete, notcascade.nullon an access gate silently opens the gate. A group that gates events, files, or products mustprevent, notnull— clearing the FK would make the content public.
prevent: a wrong prevent fails loudly with
the model and column named and is a one-line fix; a wrong cascade silently
destroys data.3. Default Behavior
Every foreign-key-shaped column must have an entry in
$foreign_key_actions — ModelTester fails the db tier for any model with a
detected foreign key and no declared action. A column that reaches
registration undeclared registers as prevent with a message naming the
model and column, so an undeclared delete path fails loudly at delete time
instead of quietly deleting rows nobody intended.
Database-Level Foreign Keys (the integrity backstop)
The PHP deletion doctrine above runs only when deletion goes through the models. Raw SQL, a crashed process, or a killed test run bypasses it — and a child row that survives its parent is worse than clutter: if the parent's primary key is ever reallocated, the stale child attaches to the new owner. For hard ownership edges, a real database constraint closes that hole.
A field spec declares one with the foreign_key key:
'uew_uev_user_encryption_vault_id' => array('type'=>'int8', 'is_nullable'=>false, 'index'=>true,
'foreign_key'=>array('table'=>'uev_user_encryption_vaults',
'column'=>'uev_user_encryption_vault_id',
'on_delete'=>'CASCADE')),update_database (and plugin sync) materializes every declaration as a real
FOREIGN KEY ... ON DELETE ... constraint: missing constraints are created,
and an existing constraint whose target or ON DELETE action differs from the
declaration is dropped and recreated — the declaration is the single source of
truth. If orphan rows block creation, the sync reports the table, relation,
and orphan count as a loud error and refuses to continue silently; clean the
orphans (a data migration), then re-run. on_delete accepts CASCADE,
SET NULL, RESTRICT, and NO ACTION.
When to declare one: the child row is meaningless or dangerous without its
parent — encryption wrappings without their vault, passkey credentials without
their user. Soft-delete flows are unaffected (soft delete never removes parent
rows), and the PHP sweeps delete children before parents, so the constraint is
a no-op behind them — the two layers cannot fight. Ordinary relationships stay
on the PHP doctrine alone; it handles sentinel values, prevent, and
per-model logic that a DB constraint cannot express.
The referential_integrity test (tests/schema/, tier safe) verifies in
every gate run that each declaration is materialized, that no declared
relation has orphan rows, and that no serial sequence sits behind its table's
MAX(pkey).
Using $foreign_key_actions in Models
A child model declares what happens to its own rows when a parent goes away. The model holding the reference is the one that knows whether losing its parent means it should vanish, be reassigned, or block the delete outright — so that decision lives with it rather than in a list kept by the parent. This also lets a plugin define behaviour for its own tables without editing a core model.
Basic Examples
Most Common: Set to Deleted User
class Order extends SystemBase {
public static $tablename = 'ord_orders';
protected static $foreign_key_actions = [
'ord_usr_user_id' => ['action' => 'set_value', 'value' => User::USER_DELETED]
];
}Prevent Deletion
class OrderItem extends SystemBase {
public static $tablename = 'odi_order_items';
protected static $foreign_key_actions = [
'odi_pro_product_id' => [
'action' => 'prevent',
'message' => 'Cannot delete product - order items exist'
]
];
}Set to NULL
class Event extends SystemBase {
public static $tablename = 'evt_events';
protected static $foreign_key_actions = [
'evt_loc_location_id' => ['action' => 'null']
];
}Cascade (owned leaf rows)
class UserActivityLog extends SystemBase {
public static $tablename = 'ual_user_activity_logs';
protected static $foreign_key_actions = [
// Log rows are owned by the user and have no children of their own
'ual_usr_user_id' => ['action' => 'cascade']
];
}Multiple Foreign Keys
Handle different foreign keys with different actions:
class Message extends SystemBase {
public static $tablename = 'msg_messages';
protected static $foreign_key_actions = [
'msg_usr_sender_id' => ['action' => 'set_value', 'value' => User::USER_DELETED],
'msg_usr_recipient_id' => ['action' => 'set_value', 'value' => User::USER_DELETED],
'msg_thread_id' => ['action' => 'cascade'] // Messages die with their thread
];
}Deletion Rule Registration Lifecycle
Core Models
Core model deletion rules are registered by update_database.php:
// In /utils/update_database.php (Step 3.5)
DeletionRule::registerModelsFromDiscovery([
'include_plugins' => false, // Core only
'verbose' => $verbose
]);When: Every time update_database.php runs
Plugin Models
Plugin deletion rules are registered/removed through PluginManager lifecycle operations:
- Plugin Activate:
PluginManager::activate()(onActivate()) registers rules for that plugin - Plugin Deactivate:
PluginManager::deactivate()(onDeactivate()) removes rules for that plugin - Plugin Uninstall:
PluginManager::uninstall()removes rules for that plugin
Manual Registration
To manually register deletion rules for all active plugins:
require_once(PathHelper::getIncludePath('includes/PluginHelper.php'));
$warnings = PluginHelper::registerAllActiveDeletionRules();Registration is idempotent: re-registering a model replaces that model's rules atomically (its existing rows are deleted, then the fresh set is inserted), so running it repeatedly converges rather than accumulating duplicates.
Orphaned Rule Pruning
PluginManager::sync() calls DeletionRule::pruneOrphanedRules() after
registering every active plugin's rules, and PluginManager::uninstall()
calls it after dropping the plugin's tables. It deletes any rule naming a
table the engine could not consult:
- a table no model on disk declares (core or plugin, active or not; discovery scans the filesystem) — a renamed or removed table, since nothing else ever revisits a registered rule once its owning column is gone;
- a table a model declares but this database does not have — an inactive or
uninstalled plugin's.
permanent_delete()counts rows in every rule's table before it deletes anything, so one rule about an absent table refuses every delete of its source (every file, say) on the whole site. The plugin's rules are registered again when it activates.
How Deletion Works
Dry Run Preview
Before deleting, check what will be affected:
$user = new User($user_id, TRUE);
$dry_run = $user->permanent_delete_dry_run();
// Returns:
// [
// 'primary' => ['table' => 'usr_users', 'key_column' => 'usr_user_id', 'key' => 123],
// 'dependencies' => [
// ['table' => 'ord_orders', 'column' => 'ord_usr_user_id', 'count' => 5,
// 'action' => 'set_value', 'action_value' => 3],
// ['table' => 'ual_user_activity_logs', 'column' => 'ual_usr_user_id',
// 'count' => 150, 'action' => 'cascade']
// ],
// 'total_affected' => 156,
// 'can_delete' => true,
// 'blocking_reasons' => []
// ]Permanent Delete
The system handles dependencies automatically:
$user = new User($user_id, TRUE);
$user->assert_can_write($session);
$user->permanent_delete();
// Automatically:
// 1. Updates orders to set usr_user_id = 3 (DELETED_USER)
// 2. Cascades delete of user activity logs
// 3. Handles all other dependencies per their rules
// 4. Deletes the user record
// 5. Commits transactionCustom Deletion Logic
Models can override permanent_delete() for custom behavior:
class User extends SystemBase {
public function permanent_delete($debug=false) {
// Custom pre-deletion work
$this->remove_from_mailing_lists();
$this->remove_group_memberships();
// Call parent to handle dependencies and delete
parent::permanent_delete($debug);
return true;
}
}Important: Custom methods should call parent::permanent_delete() to use the deletion system.
Database Structure
Deletion rules are stored in the del_deletion_rules table:
CREATE TABLE del_deletion_rules (
del_deletion_rule_id BIGSERIAL PRIMARY KEY,
del_source_table VARCHAR(255), -- Parent table (e.g., 'usr_users')
del_target_table VARCHAR(255), -- Child table (e.g., 'ord_orders')
del_target_column VARCHAR(255), -- Foreign key column (e.g., 'ord_usr_user_id')
del_action VARCHAR(50), -- 'cascade', 'permanent_delete', 'set_value', 'null', 'prevent'
del_action_value VARCHAR(255), -- Value for 'set_value' action
del_message TEXT, -- Message for 'prevent' action
del_plugin VARCHAR(255) -- Plugin name (NULL for core)
);Troubleshooting
Check Current Rules
-- See all deletion rules
SELECT * FROM del_deletion_rules ORDER BY del_source_table, del_target_table;
-- Rules for a specific table
SELECT * FROM del_deletion_rules WHERE del_source_table = 'usr_users';
-- Plugin rules only
SELECT * FROM del_deletion_rules WHERE del_plugin IS NOT NULL;
-- Count by action type
SELECT del_action, COUNT(*) FROM del_deletion_rules GROUP BY del_action;Common Issues
Problem: Deletion rules not registered for plugin Solution:
- Check if plugin is active (
plg_active = 1) - Deactivate and re-activate the plugin — activation re-registers deletion rules
- Or from CLI:
PluginHelper::registerAllActiveDeletionRules()
- Check
$foreign_key_actionsin your model class - Verify column name matches pattern:
{prefix}_{source_prefix}_{entity}_id - Re-register rules by syncing or reactivating plugin
- Check for
'prevent'actions indel_deletion_rulesfor that source table - Use
permanent_delete_dry_run()to see what's blocking deletion - Either remove dependencies or change action from 'prevent' to another action
- Already fixed in SystemBase - it checks
inTransaction()before starting new transaction - If you see this, you may have custom code starting transactions
Debug Tools
See what will be deleted:
$obj = new SomeModel($id, TRUE);
$preview = $obj->permanent_delete_dry_run();
print_r($preview);Test in debug mode (no actual deletion):
$obj->permanent_delete($debug = true); // Prints SQL without executingTechnical Implementation
Key Classes
DeletionRule (/data/deletion_rules_class.php)
registerModelsFromDiscovery($options)- Discover and register model rules; returns warning strings for unresolvable declared overridesregisterModelRules($model_class)- Register one model's rules incrementally; returns the same kind of warningspruneOrphanedRules()- Delete rules whose source or target table no model declares or this database lackstablesAbsentFromDatabase($tables)- Which of these names have no table here, as a set
/includes/SystemBase.php)
permanent_delete_dry_run()- Preview deletion impactpermanent_delete($debug)- Execute deletion with dependency handling
/includes/PluginHelper.php)
registerAllActiveDeletionRules()- Register rules for all active pluginsremovePluginDeletionRules()- Remove rules for one plugin
Algorithm
When permanent_delete() is called:
- Start transaction (if not already in one)
- Query deletion rules from
del_deletion_rulesfor this source table - For each dependent table:
- Count how many dependent records exist
- If count > 0, apply the action:
- cascade: DELETE dependent records
- permanent_delete: load each dependent as its model and call
permanent_delete()on it - set_value: UPDATE dependent records to set value - null: UPDATE dependent records to NULL - prevent: THROW error and rollback - Delete the primary record
- Commit transaction
Designing a Deletion Strategy for New Models
When creating a new model with parent-child relationships, plan for both soft delete and permanent delete:
1. Permanent Delete ($foreign_key_actions)
Declare on the child model what happens when its parent is permanently deleted:
// Child model — alias belongs to a domain. Aliases have children of their
// own (grants, filters, IMAP accounts), so the action is permanent_delete:
// each alias is loaded and deleted through its own rules. A flat 'cascade'
// here would delete the alias rows and strand everything hanging off them.
class InboundEmailAlias extends SystemBase {
protected static $foreign_key_actions = [
'iea_ied_inbound_email_domain_id' => ['action' => 'permanent_delete'],
];
}
// Grandchild model — log references an alias, preserve for auditing
class InboundEmailLog extends SystemBase {
protected static $foreign_key_actions = [
'iel_iea_inbound_email_alias_id' => ['action' => 'null'],
];
}2. Soft Delete Cascading
$foreign_key_actions only applies to permanent_delete(). Soft-delete cascading must be implemented manually in your deletion logic. When a parent is soft-deleted, children often need to be soft-deleted too:
// In admin logic — soft-delete domain cascades to aliases
$domain->soft_delete();
$aliases = new MultiInboundEmailAlias([
'domain_id' => $domain->key,
'deleted' => false,
]);
$aliases->load();
foreach ($aliases as $alias) {
$alias->soft_delete();
}3. Undelete with Cascade Awareness
When restoring a soft-deleted parent, only restore children that were deleted at the same time or after the parent. Children independently deleted before the parent should remain deleted:
$domain_delete_time = $domain->get('efd_delete_time');
$domain->undelete();
// Restore only aliases deleted when/after the domain was deleted
$sql = "UPDATE efa_email_forwarding_aliases
SET efa_delete_time = NULL
WHERE efa_efd_email_forwarding_domain_id = ?
AND efa_delete_time >= ?";
$q = $dblink->prepare($sql);
$q->execute([$domain->key, $domain_delete_time]);Checklist for New Models
- [ ] Define
$foreign_key_actionson child models for permanent delete behavior - [ ] Implement soft-delete cascade in the admin/logic layer if parent-child relationship exists
- [ ] Implement undelete logic that respects independently-deleted children
- [ ] Consider whether logs/audit records should use
'action' => 'null'to preserve history - [ ] Require appropriate permission level for permanent delete (typically 10)
Worked example: Drive trash retention
The member Drive (see Drive) is a full example of both halves plus a timed purge:
- Soft-delete cascade stamps the folder first so it holds the earliest
delete_timein its cascade; every descendant folder and file follows. - Selective restore captures the folder's
delete_timebeforeundelete()and restores only descendants withdelete_time >=it — a child trashed independently earlier stays in the trash. - Timed purge — the daily retention sweep calls
File::purgeExpiredTrash(), which callspermanent_delete()on items trashed longer than its window (default 30 days); blob reference counts reclaim the shared bytes. A file'sfil_fol_folder_iduses'action' => 'null'so a raw folder permanent-delete orphans files to the root rather than destroying them — the destructive path goes through the trash logic, not the bare deletion rule.
Worked example: mail trash retention
Mail (see Mailbox) has the same soft-delete / restore / timed-purge shape with no cascade at all — a message has no descendants, so there is nothing to restore selectively:
- Soft delete is one column.
iem_delete_time, stamped byMailboxService::softDelete(). Trash is a view* over that column, and every other read and mutation pins it NULL, so a trashed row is unreachable rather than merely hidden. - The reclaim lives in the model.
InboundEmailMessage::permanent_delete()frees the attachmentfil_Files and the stored raw object (local file or cloud object) before the row goes. This is why the purge loops row by row through the model: a bulkDELETEwould satisfy the schema and leak the bytes. - Timed purge —
PurgeMailboxTrashcallspermanent_delete()on messages trashed longer thanmailbox_trash_retention_days(default 30) ago, capped per run so a large backlog drains over several passes.
Best Practices
- Use constants for sentinel values:
User::USER_DELETEDinstead of hardcoded3 - Add messages for prevent actions: Help users understand why deletion failed
- Test deletion impact: Use
permanent_delete_dry_run()before actual deletion - Check the child for children before choosing
cascade: if the target table is itself a source table indel_deletion_rules, usepermanent_delete - When unsure,
prevent: it fails loudly and is a one-line fix; a wrongcascadesilently destroys data - Document custom permanent_delete(): Explain any special pre/post-deletion logic