POLYMORPHIC(7) RSX Framework Manual POLYMORPHIC(7) NAME polymorphic - Polymorphic relationships with type references SYNOPSIS Model definition: class Activity_Model extends Rsx_Model_Abstract { protected static $type_ref_columns = ['eventable_type']; public function eventable() { return $this->morphTo(); } } Usage: $activity->eventable_type = 'Contact_Model'; // Stores integer in DB $activity->eventable_id = 123; $activity->save(); echo $activity->eventable_type; // Returns "Contact_Model" Form handling: $eventable = Polymorphic_Field_Helper::parse($params['eventable'], [ Contact_Model::class, Project_Model::class, ]); DESCRIPTION Polymorphic relationships allow a single database column pair to reference multiple model types. For example, an Activity can be related to either a Contact or a Project using eventable_type and eventable_id columns. RSX uses a type reference system that stores integers in the database but transparently converts to/from class name strings in PHP. This provides: - Efficient integer storage instead of VARCHAR class names - Automatic type discovery - no manual mapping required - Transparent conversion - you work with class names, never see integers - Laravel morphTo() compatibility through auto-registered morph maps TYPE REFERENCE SYSTEM How It Works RSX maintains a _type_refs table that maps class names to integer IDs: id | class_name | table_name 1 | Contact_Model | contacts 2 | Project_Model | projects 3 | Task_Model | tasks When you set a type_ref column to a class name, RSX: 1. Checks if the class exists in _type_refs 2. If not, validates it's a valid Rsx_Model subclass and creates entry 3. Stores the integer ID in the database When you read a type_ref column, RSX: 1. Looks up the integer ID in _type_refs 2. Returns the class name string Defining Type Reference Columns Declare which columns are type references in your model: class File_Attachment_Model extends Rsx_Model_Abstract { protected static $type_ref_columns = ['fileable_type']; public function fileable() { return $this->morphTo(); } } The cast is automatically applied - no manual $casts definition needed. Database Schema Type reference columns must be BIGINT, not VARCHAR: fileable_type BIGINT NULL, fileable_id BIGINT NULL, The _type_refs table is created automatically by framework migrations. Auto-Discovery When storing a new class name that isn't in _type_refs yet: $attachment->fileable_type = 'Custom_Model'; $attachment->save(); RSX will: 1. Verify Custom_Model exists and extends Rsx_Model_Abstract 2. Create a new _type_refs entry with the next available ID 3. Store that ID in the fileable_type column This means any model can be used in polymorphic relationships without pre-registration. The system is fully automatic. Caching The type refs map is cached: 1. In Redis (if available) - refreshed when entries are added 2. In PHP memory - loaded once per request The map is typically small (< 100 entries), so full-map caching is used. Validation On write: Throws exception if value is not a valid Rsx_Model subclass On read: Throws exception if ID doesn't exist in _type_refs table Null values: Allowed if the database column is nullable LARAVEL MORPH MAP INTEGRATION RSX automatically registers all type refs with Laravel's morph map during framework initialization. This means standard Laravel polymorphic methods work transparently: // These all work as expected $attachment->fileable; // Returns the related model $model->attachments; // morphMany relationship Activity_Model::whereHasMorph(); // Polymorphic queries The morph map uses simple class names (e.g., "Contact_Model") not fully qualified names, matching how RSX models work throughout the framework. QUERY BUILDER INTEGRATION Transparent WHERE Clauses The query builder automatically converts type_ref columns in WHERE clauses. You can use class name strings directly: // All of these work - class names auto-converted to IDs File_Attachment_Model::where('fileable_type', 'Contact_Model')->get(); File_Attachment_Model::where('fileable_type', '=', 'Contact_Model')->get(); File_Attachment_Model::where(['fileable_type' => 'Contact_Model'])->get(); // whereIn also works File_Attachment_Model::whereIn('fileable_type', [ 'Contact_Model', 'Project_Model' ])->get(); // Integer IDs still work (pass-through) File_Attachment_Model::where('fileable_type', 42)->get(); Supported methods: where(), orWhere(), whereIn(), orWhereIn(), whereNotIn(), orWhereNotIn() Polymorphic Join Helpers Join tables with polymorphic columns using dedicated helpers: // Get contacts that have attachments (INNER JOIN) Contact_Model::query() ->joinMorph('file_attachments', 'fileable') ->select('contacts.*', 'file_attachments.filename') ->get(); // Get all contacts, with attachments if they exist (LEFT JOIN) Contact_Model::query() ->leftJoinMorph('file_attachments', 'fileable') ->get(); // RIGHT JOIN Contact_Model::query() ->rightJoinMorph('file_attachments', 'fileable') ->get(); Parameters: $table - Table with polymorphic columns (e.g., 'file_attachments') $morphName - Column prefix (e.g., 'fileable' for fileable_type/fileable_id) $morphClass - Optional class name (defaults to current model) Explicit class (when querying from a different model context): SomeModel::query() ->leftJoinMorph('file_attachments', 'fileable', Contact_Model::class) ->get(); The generated SQL is equivalent to: LEFT JOIN file_attachments ON file_attachments.fileable_id = contacts.id AND file_attachments.fileable_type = FORM HANDLING Client-Side Format Polymorphic fields are submitted as JSON-encoded values: eventable={"model":"Contact_Model","id":123} This single-field approach simplifies form handling compared to separate type and id fields. Polymorphic_Field_Helper Server-side parsing with security validation: use App\RSpade\Core\Polymorphic_Field_Helper; $eventable = Polymorphic_Field_Helper::parse($params['eventable'], [ Contact_Model::class, Project_Model::class, ]); // Validation if ($error = $eventable->validate('Please select an entity')) { $errors['eventable'] = $error; } // Access values $model->eventable_type = $eventable->model; // "Contact_Model" $model->eventable_id = $eventable->id; // 123 The whitelist ensures only allowed model types are accepted. Validation Methods Required field: if ($error = $field->validate('Please select')) { $errors['field'] = $error; } Optional field: if ($error = $field->validate_optional('Invalid type')) { $errors['field'] = $error; } State Checking $field->is_empty() // No value provided $field->is_valid() // Value provided and model type allowed $field->is_invalid() // Value provided but model type not allowed CLIENT-SIDE IMPLEMENTATION Building a Polymorphic Picker Component To create a picker for polymorphic fields, build a component that: 1. Maintains a hidden input with the JSON-encoded value 2. Implements val() to get/set as {model: 'Model_Name', id: number} 3. Syncs the hidden input on every change 4. Handles pre-load value setting (value set before options loaded) Hidden Input Pattern The component template should include a hidden input: On value change, sync to hidden input as JSON: _sync_hidden_value() { const $hidden = this.$sid('hidden_value'); if (this._value && this._value.model && this._value.id) { $hidden.val(JSON.stringify(this._value)); } else { $hidden.val(''); } } val() Implementation The val() method should work before and after component loads: val(value) { if (arguments.length === 0) { // Getter return this._value || this._pending_value || null; } // Setter const parsed = value ? {model: value.model, id: parseInt(value.id)} : null; if (this.data.loading) { this._pending_value = parsed; this._value = parsed; this._sync_hidden_value(); } else { this._value = parsed; this._apply_to_ui(); this._sync_hidden_value(); } } Using Tom Select For searchable dropdowns, use Tom Select with a compound value format: // Compound value: "Model_Name:id" valueField: 'compound_value', onChange: function(compound_value) { if (!compound_value) { that._value = null; } else { const parts = compound_value.split(':'); that._value = {model: parts[0], id: parseInt(parts[1])}; } that._sync_hidden_value(); } Add options with the compound format: all_options.push({ compound_value: 'Contact_Model:' + contact.id, model: 'Contact_Model', id: contact.id, name: contact.name, }); COMPLETE EXAMPLE Model Definition class Activity_Model extends Rsx_Model_Abstract { protected static $type_ref_columns = ['eventable_type']; public static $enums = []; public static $rel = []; protected $table = 'activities'; public function eventable() { return $this->morphTo(); } } Controller use App\RSpade\Core\Polymorphic_Field_Helper; #[Ajax_Endpoint] public static function save(Request $request, array $params = []) { $eventable = Polymorphic_Field_Helper::parse($params['eventable'] ?? null, [ Contact_Model::class, Project_Model::class, ]); $errors = []; if ($error = $eventable->validate('Please select an entity')) { $errors['eventable'] = $error; } if (!empty($errors)) { return response_form_error('Please correct errors', $errors); } $activity = new Activity_Model(); $activity->eventable_type = $eventable->model; $activity->eventable_id = $eventable->id; $activity->save(); return ['success' => true]; } Migration DB::statement(" CREATE TABLE activities ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, eventable_type BIGINT NULL, eventable_id BIGINT NULL, description TEXT NULL, site_id BIGINT NOT NULL, created_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3), updated_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), created_by BIGINT NULL, updated_by BIGINT NULL, INDEX idx_eventable (eventable_type, eventable_id), FOREIGN KEY (site_id) REFERENCES sites(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 "); RSX VS LARAVEL Laravel Default - Stores full class name as VARCHAR: "App\\Models\\Contact" - Requires manual morph map configuration for aliases - No automatic discovery of new types RSX Approach - Stores integer ID in BIGINT column - Automatic class name to ID mapping via _type_refs table - Auto-discovery creates new entries on first use - Morph map auto-registered for Laravel relationship compatibility - Simple class names: "Contact_Model" not "App\\Models\\Contact" Benefits - Smaller database storage (BIGINT vs VARCHAR) - Faster queries (integer comparison vs string comparison) - Indexable without concerns about string length - Consistent with RSX simple class name conventions - Zero configuration - just declare $type_ref_columns CONVERTING EXISTING COLUMNS To convert a VARCHAR polymorphic column to use type_refs: 1. Create migration to add temporary column and convert data 2. Update model to declare $type_ref_columns 3. Drop old VARCHAR column Example migration (if you have existing data to preserve): // Add new integer column DB::statement("ALTER TABLE my_table ADD COLUMN entity_type_new BIGINT NULL AFTER entity_type"); // Note: Data migration would go here if needed // For new installations, just drop and recreate the column // Drop old column and rename DB::statement("ALTER TABLE my_table DROP COLUMN entity_type"); DB::statement("ALTER TABLE my_table CHANGE entity_type_new entity_type BIGINT NULL"); For fresh installations with no data to preserve, simply: DB::statement("ALTER TABLE my_table MODIFY entity_type BIGINT NULL"); SECURITY CONSIDERATIONS Model Type Validation CRITICAL: Always validate that submitted model types are in the allowed list. Attackers could submit arbitrary model names to exploit the polymorphic relationship. The Polymorphic_Field_Helper enforces this automatically: // Only Contact_Model and Project_Model are accepted $eventable = Polymorphic_Field_Helper::parse($params['eventable'], [ Contact_Model::class, Project_Model::class, ]); // Submitting {"model":"User_Model","id":1} will fail validation $eventable->is_valid(); // false Type Ref Auto-Creation The type_refs system validates that any new class name is a valid Rsx_Model subclass before creating an entry. This prevents attackers from polluting the _type_refs table with invalid class names. Use Model::class, Not Strings Always use the ::class constant to specify allowed models: // CORRECT - uses class constant Polymorphic_Field_Helper::parse($value, [Contact_Model::class]); // WRONG - hardcoded string Polymorphic_Field_Helper::parse($value, ['Contact_Model']); Using ::class ensures: - IDE autocompletion and refactoring support - Compile-time error if class doesn't exist - Consistent naming with actual class names SEE ALSO model(7), form_conventions(7), enum(7) RSX Framework 2025-12-27 POLYMORPHIC(7)