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 automatically detects foreign keys based on column naming:
Pattern: {prefix}_{source_prefix}_{entity}_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 tableThe system:
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'));
PluginHelper::registerAllActiveDeletionRules();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 rulesregisterModelRules($model_class) - Register one model's rules incrementallygetSourceTableFromColumn($column) - Parse foreign key column to find source table/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 historyUser::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.