solution-forest/laravel-dynamic-properties

A fast, flexible dynamic property system for Laravel entities

v1.0.0 2025-08-15 09:27 UTC

This package is auto-updated.

Last update: 2025-08-15 10:53:19 UTC


README

Tests Code Style Latest Stable Version License

A dynamic property system for Laravel that allows any entity (users, companies, contacts, etc.) to have custom properties with validation, search capabilities, and optimal performance.

Requirements

  • PHP: 8.3 or higher
  • Laravel: 11.0 or higher
  • Database: MySQL 8.0+ or SQLite 3.35+ (with JSON support)

Features

  • Simple Architecture: Clean 2-table design with optional JSON caching
  • Type Safety: Support for text, number, date, boolean, and select properties
  • Fast Performance: < 1ms property retrieval with JSON cache, < 20ms without
  • Flexible Search: Property-based filtering with multiple operators
  • Easy Integration: Simple trait-based implementation
  • Database Agnostic: Works with MySQL and SQLite
  • Validation: Built-in property validation with custom rules

Installation

Install the package via Composer:

composer require solution-forest/laravel-dynamic-properties

Publish and run the migrations:

php artisan vendor:publish --provider="SolutionForest\LaravelDynamicProperties\DynamicPropertyServiceProvider" --tag="migrations"
php artisan migrate

Optionally, publish the configuration file:

php artisan vendor:publish --provider="SolutionForest\LaravelDynamicProperties\DynamicPropertyServiceProvider" --tag="config"

Quick Start

⚠️ IMPORTANT: You must create Property definitions before setting property values. Attempting to set a property that doesn't have a definition will throw a PropertyNotFoundException.

1. Add the Trait to Your Models

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use SolutionForest\LaravelDynamicProperties\Traits\HasProperties;

class User extends Model
{
    use HasProperties;
    
    // Your existing model code...
}

2. Create Properties

⚠️ REQUIRED STEP: Before setting any property values, you must first create the property definitions. This ensures type safety, validation, and optimal performance.

use SolutionForest\LaravelDynamicProperties\Models\Property;

// Create a text property
Property::create([
    'name' => 'phone',
    'label' => 'Phone Number',
    'type' => 'text',
    'required' => false,
    'validation' => ['min' => 10, 'max' => 15]
]);

// Create a number property
Property::create([
    'name' => 'age',
    'label' => 'Age',
    'type' => 'number',
    'required' => true,
    'validation' => ['min' => 0, 'max' => 120]
]);

// Create a select property
Property::create([
    'name' => 'status',
    'label' => 'User Status',
    'type' => 'select',
    'required' => true,
    'options' => ['active', 'inactive', 'pending']
]);

3. Set and Get Properties

✅ Only after creating property definitions can you set values:

$user = User::find(1);

// ✅ This works - property 'phone' was defined above
$user->setDynamicProperty('phone', '+1234567890');
$user->setDynamicProperty('age', 25);
$user->setDynamicProperty('status', 'active');

// ❌ This will throw PropertyNotFoundException
$user->setDynamicProperty('undefined_property', 'value');

// Or use magic methods
$user->prop_phone = '+1234567890';
$user->prop_age = 25;

// Get properties
$phone = $user->getDynamicProperty('phone');
$age = $user->prop_age; // Magic method
$allProperties = $user->properties; // All properties as array

// Set multiple properties at once
$user->setProperties([
    'phone' => '+1234567890',
    'age' => 25,
    'status' => 'active'
]);

💡 Pro Tip: Use the Artisan command to create properties interactively:

php artisan dynamic-properties:create

4. Search by Properties

🔍 Search works with or without property definitions, but defining properties first is strongly recommended for type safety:

// ✅ RECOMMENDED: Search with defined properties (uses correct column types)
$activeUsers = User::whereProperty('status', 'active')->get();
$youngUsers = User::whereProperty('age', '<', 30)->get();

// ⚠️ FALLBACK: Search undefined properties (uses value-based type detection)
$results = User::whereProperty('undefined_prop', 'some_value')->get();

// Find users by multiple properties
$users = User::whereProperties([
    'status' => 'active',
    'age' => 25
])->get();

⚠️ Common Pitfalls and Warnings

1. Property Definition Required for Setting Values

// ❌ WRONG - Will throw PropertyNotFoundException
$user->setDynamicProperty('new_field', 'value'); // Property 'new_field' doesn't exist

// ✅ CORRECT - Create property definition first
Property::create([
    'name' => 'new_field',
    'label' => 'New Field',
    'type' => 'text'
]);
$user->setDynamicProperty('new_field', 'value'); // Now it works

2. Type Safety Depends on Property Definitions

// ✅ With property definition - Type safe
Property::create(['name' => 'score', 'type' => 'number']);
$users = User::whereProperty('score', '>', 80); // Uses number_value column correctly

// ⚠️ Without property definition - Fallback behavior
$users = User::whereProperty('undefined_score', '>', 80); // Uses value-based type detection

3. Validation Only Works with Property Definitions

// ✅ With validation rules
Property::create([
    'name' => 'email',
    'type' => 'text',
    'validation' => ['email', 'required']
]);
$user->setDynamicProperty('email', 'invalid-email'); // Throws PropertyValidationException

// ❌ Without property definition - No validation possible
// (Would throw PropertyNotFoundException before validation could occur)

4. Performance Impact

// ✅ FAST - Uses correct column and indexes
Property::create(['name' => 'department', 'type' => 'text']);
$users = User::whereProperty('department', 'engineering'); // Optimized query

// ⚠️ SLOWER - Uses fallback type detection
$users = User::whereProperty('undefined_dept', 'engineering'); // Less optimal

Advanced Usage

Property Types and Validation

Text Properties

Property::create([
    'name' => 'bio',
    'label' => 'Biography',
    'type' => 'text',
    'validation' => [
        'min' => 10,        // Minimum length
        'max' => 500,       // Maximum length
        'required' => true  // Required field
    ]
]);

Number Properties

Property::create([
    'name' => 'salary',
    'label' => 'Annual Salary',
    'type' => 'number',
    'validation' => [
        'min' => 0,
        'max' => 1000000,
        'decimal_places' => 2
    ]
]);

Date Properties

Property::create([
    'name' => 'hire_date',
    'label' => 'Hire Date',
    'type' => 'date',
    'validation' => [
        'after' => '2020-01-01',
        'before' => 'today'
    ]
]);

Boolean Properties

Property::create([
    'name' => 'newsletter_subscribed',
    'label' => 'Newsletter Subscription',
    'type' => 'boolean',
    'required' => false
]);

Select Properties

Property::create([
    'name' => 'department',
    'label' => 'Department',
    'type' => 'select',
    'options' => ['engineering', 'marketing', 'sales', 'hr'],
    'required' => true
]);

Performance Optimization

JSON Column Caching

For maximum performance, add a JSON column to your existing tables:

// In a migration
Schema::table('users', function (Blueprint $table) {
    $table->json('dynamic_properties')->nullable();
});

Schema::table('companies', function (Blueprint $table) {
    $table->json('dynamic_properties')->nullable();
});

This provides:

  • < 1ms property retrieval (vs ~20ms without cache)
  • Automatic synchronization when properties change
  • Transparent fallback to EAV structure when cache is unavailable

Search Performance

The package automatically creates optimized indexes:

-- Indexes for fast property search
INDEX idx_string_search (entity_type, property_name, string_value)
INDEX idx_number_search (entity_type, property_name, number_value)
INDEX idx_date_search (entity_type, property_name, date_value)
INDEX idx_boolean_search (entity_type, property_name, boolean_value)
FULLTEXT INDEX ft_string_content (string_value)

Advanced Search

Complex Queries

use YourVendor\DynamicProperties\Services\PropertyService;

$propertyService = app(PropertyService::class);

// Advanced search with operators
$results = $propertyService->search('App\\Models\\User', [
    'age' => ['value' => 25, 'operator' => '>='],
    'salary' => ['value' => 50000, 'operator' => '>'],
    'status' => 'active'
]);

Text Search

// Full-text search on text properties
$users = User::whereRaw(
    "EXISTS (SELECT 1 FROM entity_properties ep WHERE ep.entity_id = users.id 
     AND ep.entity_type = ? AND MATCH(ep.string_value) AGAINST(? IN BOOLEAN MODE))",
    ['App\\Models\\User', '+marketing +manager']
)->get();

Error Handling

The package provides comprehensive error handling:

use YourVendor\DynamicProperties\Exceptions\PropertyNotFoundException;
use YourVendor\DynamicProperties\Exceptions\PropertyValidationException;

try {
    $user->setDynamicProperty('nonexistent_property', 'value');
} catch (PropertyNotFoundException $e) {
    // Handle property not found
    echo "Property not found: " . $e->getMessage();
}

try {
    $user->setDynamicProperty('age', 'invalid_number');
} catch (PropertyValidationException $e) {
    // Handle validation error
    echo "Validation failed: " . $e->getMessage();
}

Artisan Commands

The package includes helpful Artisan commands:

  dynamic-properties:create       Create a new dynamic property
  dynamic-properties:delete       Delete a dynamic property and all its values
  dynamic-properties:list         List all dynamic properties
  dynamic-properties:optimize-db  Optimize database for dynamic properties with database-specific enhancements
  dynamic-properties:sync-cache   Synchronize JSON cache columns with entity properties

API Reference

HasProperties Trait

Methods

setDynamicProperty(string $name, mixed $value): void

  • Sets a single property value
  • Validates the value against property rules
  • Updates JSON cache if available

getDynamicProperty(string $name): mixed

  • Retrieves a single property value
  • Returns null if property doesn't exist

setProperties(array $properties): void

  • Sets multiple properties at once
  • More efficient than multiple setDynamicProperty calls

getPropertiesAttribute(): array

  • Returns all properties as an associative array
  • Uses JSON cache when available, falls back to EAV queries

Magic Methods

__get($key): mixed

  • Access properties with prop_ prefix
  • Example: $user->prop_phone gets the 'phone' property

__set($key, mixed $value): void

  • Set properties with prop_ prefix
  • Example: $user->prop_phone = '+1234567890' sets the 'phone' property

Query Scopes

whereProperty(string $name, mixed $value, string $operator = '='): Builder

  • Filter entities by a single property
  • Supports operators: =, !=, <, >, <=, >=, LIKE

whereProperties(array $properties): Builder

  • Filter entities by multiple properties
  • Uses AND logic between properties

PropertyService

setDynamicProperty(Model $entity, string $name, mixed $value): void

  • Core method for setting property values
  • Handles validation and storage

setProperties(Model $entity, array $properties): void

  • Set multiple properties efficiently

search(string $entityType, array $filters): Collection

  • Advanced search with complex criteria
  • Supports multiple operators and property types

Performance Characteristics

Single Entity Property Retrieval

Method Performance Use Case
JSON Column Cache < 1ms Entities with many properties (50+)
EAV Fallback < 20ms Entities with few properties
Mixed Access Automatic Transparent performance optimization

Search Performance

Dataset Size Single Property Multiple Properties Full-Text Search
1K entities < 10ms < 50ms < 100ms
10K entities < 50ms < 200ms < 500ms
100K entities < 200ms < 1s < 2s

Memory Usage

  • Property definitions: ~1KB per property
  • Entity properties: ~100 bytes per property value
  • JSON cache: ~50% reduction in query overhead

Database Compatibility

MySQL (Recommended)

  • Full JSON support with native functions
  • Full-text search capabilities
  • Optimal performance with all features

SQLite

  • JSON stored as TEXT with JSON1 extension
  • Basic text search with LIKE queries
  • All core functionality supported

Configuration

Publish the config file to customize behavior:

// config/dynamic-properties.php
return [
    // Default property validation rules
    'default_validation' => [
        'text' => ['max' => 1000],
        'number' => ['min' => -999999, 'max' => 999999],
    ],
    
    // Enable/disable JSON caching
    'json_cache_enabled' => true,
    
    // Cache sync strategy
    'cache_sync_strategy' => 'immediate', // 'immediate', 'deferred', 'manual'
    
    // Database-specific optimizations
    'database_optimizations' => [
        'mysql' => [
            'use_json_functions' => true,
            'enable_fulltext_search' => true,
        ],
        'sqlite' => [
            'use_json1_extension' => true,
        ],
    ],
];

Troubleshooting

Common Errors and Solutions

PropertyNotFoundException

// Error: "Property 'phone' not found"
$user->setDynamicProperty('phone', '+1234567890');

Solution: Create the property definition first:

Property::create(['name' => 'phone', 'type' => 'text']);
$user->setDynamicProperty('phone', '+1234567890'); // Now works

PropertyValidationException

// Error: "Validation failed for property 'age'"
$user->setDynamicProperty('age', -5);

Solution: Check property validation rules:

$property = Property::where('name', 'age')->first();
var_dump($property->validation); // See what rules are defined
$user->setDynamicProperty('age', 25); // Use valid value

Inconsistent Search Results

// Getting different results for the same logical query
$users1 = User::whereProperty('level', '>', 5)->get();   // 10 results
$users2 = User::whereProperty('level', '>', '5')->get(); // 3 results

Solution: This happens when property definition is missing. Create it:

Property::create(['name' => 'level', 'type' => 'number']);
// Now both queries will return the same results

Testing

The package includes comprehensive tests. Run them with:

# Run all tests
./vendor/bin/pest

# Run specific test suites
./vendor/bin/pest tests/Unit
./vendor/bin/pest tests/Feature

# Run with coverage
./vendor/bin/pest --coverage

Contributing

Please see CONTRIBUTING.md for details on how to contribute to this project.

License

This package is open-sourced software licensed under the MIT license.

Changelog

Please see CHANGELOG.md for recent changes.

Documentation

Credits