blackcube / elastic
Dynamic model attributes from JSON Schema — EAV without the pain
Installs: 4
Dependents: 1
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/blackcube/elastic
Requires
- php: >=8.3
- ext-intl: *
- ext-json: *
- blackcube/magic-compose: ^1.0
- swaggest/json-schema: ^0.12.43
- yiisoft/active-record: ^1.0
- yiisoft/db: ^2.0
- yiisoft/validator: ^2.5
Requires (Dev)
- codeception/codeception: ^5.3
- codeception/module-asserts: ^3.3
- codeception/module-db: ^3.2
- vlucas/phpdotenv: ^5.6
- yiisoft/cache-file: ^3.2
- yiisoft/db-migration: ^2.0
- yiisoft/db-mysql: ^2.0
- yiisoft/di: ^1.4
- yiisoft/event-dispatcher: ^1.1
- yiisoft/factory: ^1.3
- yiisoft/test-support: ^3.2
This package is auto-updated.
Last update: 2026-02-27 08:38:42 UTC
README
⚠️ Blackcube Warning
This is not EAV. If you want Entity-Attribute-Value with JOIN hell, look elsewhere.
Elastic stores JSON, validates with JSON Schema, and lets you query virtual columns. You manipulate PHP properties. You never see the JSON.
Dynamic model attributes from JSON Schema.
Installation
composer require blackcube/elastic
Requirements
- MySQL/MariaDB (for JSON column support)
Why Elastic?
| Approach | Problem |
|---|---|
| One table per type | 20 types = 20 tables, duplicated code |
| Catch-all columns | "field23 is what again?" |
| Raw HTML | Not validatable, not queryable, XSS |
| EAV | JOIN on JOIN on JOIN |
| Elastic | None of the above |
You manipulate PHP properties. Elastic handles JSON underneath.
Validation is automatic. JSON Schema → Yii3 Validator rules.
Queries are transparent. ->where(['virtualColumn' => 'value']) just works.
Evolution without migration. Add a field to the schema. No SQL migration needed.
How It Works
Storage
| Column | Purpose |
|---|---|
elasticSchemaId |
FK to elasticSchemas table |
_extras |
JSON data storage |
The developer never touches _extras directly. Properties are accessed like regular PHP properties.
Column names can be tuned
Override these methods in your model to use different column names:
public function elasticColumn(): string { return 'data'; } // Default: '_extras' public function elasticSchemaColumn(): string { return 'schemaId'; } // Default: 'elasticSchemaId'
Database Setup
1. Create the schemas table
Run the provided migration:
use Blackcube\Elastic\Migrations\M000000000000CreateElasticSchemas; $migration = new M000000000000CreateElasticSchemas(); $migration->up($builder);
2. Add columns to your table
CREATE TABLE products ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(255) NOT NULL, elasticSchemaId INT, _extras TEXT, FOREIGN KEY (elasticSchemaId) REFERENCES elasticSchemas(id) );
Quick Start
1. Create a JSON Schema
use Blackcube\Elastic\ElasticSchema; $schema = new ElasticSchema(); $schema->setName('ProductAttributes'); $schema->setSchema(json_encode([ 'type' => 'object', 'properties' => [ 'sku' => ['type' => 'string', 'minLength' => 3], 'price' => ['type' => 'number', 'minimum' => 0], 'inStock' => ['type' => 'boolean'], ], 'required' => ['sku'], ])); $schema->save();
2. Create your ActiveRecord model
<?php declare(strict_types=1); namespace App\Model; use Blackcube\Elastic\ElasticInterface; use Blackcube\Elastic\ElasticTrait; use Blackcube\MagicCompose\ActiveRecord\MagicComposeActiveRecordTrait; use Yiisoft\ActiveRecord\ActiveRecord; class Product extends ActiveRecord implements ElasticInterface { use MagicComposeActiveRecordTrait; use ElasticTrait; protected string $name = ''; public function tableName(): string { return 'products'; } public function getName(): string { return $this->name; } public function setName(string $name): void { $this->name = $name; } }
Usage
Working with dynamic attributes
// Create — properties are PHP, not JSON $product = new Product(); $product->setName('Laptop'); $product->elasticSchemaId = $schemaId; $product->sku = 'LAP-001'; // Virtual property $product->price = 999.99; // Virtual property $product->inStock = true; // Virtual property $product->insert(); // Read — same thing $loaded = Product::query()->where(['id' => $product->id])->one(); echo $loaded->sku; // 'LAP-001' echo $loaded->price; // 999.99 echo $loaded->inStock; // true // Update — still PHP $loaded->price = 899.99; $loaded->update();
Querying virtual columns
ElasticQuery transforms virtual columns to JSON_VALUE() expressions automatically:
// Filter by virtual column $products = Product::query() ->where(['sku' => 'LAP-001']) ->all(); // Multiple conditions $products = Product::query() ->where(['inStock' => true]) ->andWhere(['>', 'price', 500]) ->all(); // Order by virtual column $products = Product::query() ->orderBy(['price' => SORT_DESC]) ->all(); // Mix real and virtual columns $products = Product::query() ->where(['name' => 'Laptop', 'inStock' => true]) ->orderBy(['price' => SORT_ASC]) ->all();
Validating elastic attributes
use Blackcube\Elastic\Validator\ElasticRuleResolver; use Yiisoft\Validator\Validator; $resolver = new ElasticRuleResolver(); $rules = $resolver->resolve($product); $validator = new Validator(); $result = $validator->validate($product->getElasticValues(), $rules); if (!$result->isValid()) { foreach ($result->getErrors() as $error) { echo $error->getMessage(); } }
Supported JSON Schema features
| JSON Schema | Yii3 Validator Rule |
|---|---|
type: string |
StringValue |
type: integer |
Integer |
type: number |
Number |
type: boolean |
BooleanValue |
minimum, maximum |
Integer/Number with constraints |
minLength, maxLength |
Length |
pattern |
Regex |
enum |
In |
format: email |
Email |
format: idn-email |
Email with IDN |
format: url |
Url |
format: ipv4, format: ipv6 |
Ip |
required |
Required |
Labels, hints, placeholders from schema
JSON Schema metadata is extracted automatically:
| JSON Schema field | Method |
|---|---|
title |
getPropertyLabel($property) |
description |
getPropertyHint($property) |
placeholder |
getPropertyPlaceholder($property) |
Let's be honest
Performance on complex queries
JSON_VALUE() is slower than a native indexed column. Filtering 100,000 rows on a JSON field will be slow.
In practice: A CMS with a few thousand contents? No problem. A search engine on millions of rows? Use Elasticsearch or a real column.
No foreign keys in JSON
You can't JOIN on a JSON value. If you need relations, use real columns.
One-way compatibility
Adding optional fields: ✓ works, old data returns null.
Removing fields: data stays in database, but property is no longer accessible.
Rules
- Never modify
_extrasdirectly — use dynamic properties - Link your model to a schema — set
elasticSchemaIdbefore using elastic attributes - Use
ElasticQuery— thequery()method returns it automatically via the trait
License
BSD-3-Clause. See LICENSE.md.
Author
Philippe Gaultier philippe@blackcube.io