Skip to content

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.

License

Notifications You must be signed in to change notification settings

solutionforest/laravel-dynamic-properties

Repository files navigation

Laravel Dynamic Properties

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

About

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.

Resources

License

Contributing

Stars

Watchers

Forks

Packages

No packages published

Languages