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.
xxx_yyy_entity_id)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_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:
{prefix}_usr, pro, rcr, iea)$prefix
and real $tablename (core and every plugin, built once and cached) — the
column only counts as a foreign key if the remainder also contains _id
and the first segment is a real, registered model prefixowner, 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.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.
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 |
cascade vs permanent_delete: Use cascade (the default) for leaf tables that have no children and no custom deletion logic. Use permanent_delete when the dependent model has its own permanent_delete() override or has child tables that need recursive cleanup. permanent_delete is slower (loads each record individually) but enables multi-level cascading.If no $foreign_key_actions is specified:
cascade (dependent records are deleted)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']
];
}No Configuration Needed (Cascade)
class UserActivityLog extends SystemBase {
public static $tablename = 'ual_user_activity_logs';
// No $foreign_key_actions needed!
// ual_usr_user_id will automatically cascade delete
}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'] // Optional: explicit cascade
];
}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 deletion rules are registered/removed through PluginManager lifecycle operations:
PluginManager::activate() (onActivate()) registers rules for that pluginPluginManager::deactivate() (onDeactivate()) removes rules for that pluginPluginManager::uninstall() removes rules for that pluginTo 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.
PluginManager::sync() calls DeletionRule::pruneOrphanedRules() after
registering every active plugin's rules — it deletes any rule whose source or
target table matches no currently-loaded model (core or plugin, active or
not; discovery scans the filesystem, not activation state). This is what
clears out rules left behind by a renamed or removed table, since nothing
else ever revisits an already-registered rule once its owning column is gone.
Safe to call at any time — it only ever removes rules referencing a table
nothing declares.
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' => []
// ]The system handles dependencies automatically:
$user = new User($user_id, TRUE);
$user->authenticate_write(['current_user_id' => $session_id, 'current_user_permission' => 10]);
$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 transactionModels 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.
Deletion rules are stored in the del_deletion_rules table:
CREATE TABLE del_deletion_rules (
del_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', '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)
);-- 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;Problem: Deletion rules not registered for plugin Solution:
plg_active = 1)PluginHelper::registerAllActiveDeletionRules()$foreign_key_actions in your model class{prefix}_{source_prefix}_{entity}_id'prevent' actions in del_deletion_rules for that source tablepermanent_delete_dry_run() to see what's blocking deletioninTransaction() before starting new transactionSee 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 executingDeletionRule (/data/deletion_rule_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 matches no currently-loaded model/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 pluginWhen permanent_delete() is called:
del_deletion_rules for this source tableWhen creating a new model with parent-child relationships, plan for both soft delete and 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
class InboundEmailAlias extends SystemBase {
protected static $foreign_key_actions = [
'iea_ied_inbound_email_domain_id' => ['action' => 'cascade'],
];
}
// 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'],
];
}$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();
}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]);$foreign_key_actions on child models for permanent delete behavior'action' => 'null' to preserve historyThe member Drive (see Drive) is a full example of both halves plus a timed purge:
delete_time in its cascade; every descendant folder and file follows.delete_time before undelete() and
restores only descendants with delete_time >= it — a child trashed
independently earlier stays in the trash.DrivePurgeTrash scheduled task calls permanent_delete()
on items trashed longer than its window (default 30 days); blob reference counts
reclaim the shared bytes. A file's fil_fol_folder_id uses '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.User::USER_DELETED instead of hardcoded 3permanent_delete_dry_run() before actual deletionThe old system used $permanent_delete_actions in parent models:
// OLD (deprecated)
class User extends SystemBase {
public static $permanent_delete_actions = [
'ord_usr_user_id' => User::USER_DELETED // Parent declares child behavior
];
}New system uses $foreign_key_actions in child models:
// NEW (current)
class Order extends SystemBase {
protected static $foreign_key_actions = [
'ord_usr_user_id' => ['action' => 'set_value', 'value' => User::USER_DELETED]
];
}Why the change?
$permanent_delete_actions declarations have been removed in favor of $foreign_key_actions.