Building Dynamic Custom Fields in Laravel Without Database Migrations: A Deep Dive
How we solved the "just one more field" problem at scale
Contents
- 1 The Problem: The Migration Treadmill
- 2 Architecture Overview: The Core Challenge
- 3 The EAV Pattern (With Improvements)
- 4 The Trade-off
- 5 Implementation: The Field Type System
- 6 Trait-Based Composition Over Inheritance
- 7 Dynamic Field Resolution
- 8 Multi-Tenancy: Isolating Data at Scale
- 9 The Tenant Scoping Challenge
- 10 Automatic Tenant Injection
- 11 Query Performance With Multi-Tenancy
- 12 Type Safety: Achieving 99.9% Coverage
- 13 The Static Analysis Strategy
- 14 PHP 8.4 Features for Type Safety
- 15 Architecture Testing With Pest
- 16 AI Integration: Claude API at Scale
- 17 The Context Building Challenge
- 18 Cost Optimization Through Caching
- 19 Performance Lessons
- 20 1. The N+1 Problem With Custom Fields
- 21 2. Polymorphic Query Optimization
- 22 3. Memory Management for Bulk Operations
- 23 Real-World Metrics
- 24 Conclusion: Was It Worth It?
- 25 Full Implementation
- 26 Code Examples Available
The Problem: The Migration Treadmill
Every Laravel developer knows this pattern:
Product Manager: "Can we add a field for X?" Developer: *writes migration* *deployment* *testing* *coordination* Product Manager: "Actually, can we also add Y and Z?"
In a CRM context, this becomes untenable. Different clients need different fields. A real estate agency needs "Square Footage" and "Number of Bedrooms." A SaaS company needs "MRR" and "Churn Risk." An agency needs "Contract End Date" and "Renewal Status."
Traditional solution: Write migrations for every field request.
Result: Hundreds of migrations, bloated schemas, coordination nightmares.
Our solution: Let users create their own fields dynamically—no migrations, no deployments, no developer intervention.
This article explains the architecture we built to make this work in production with Laravel 12, supporting 18 field types, multi-tenancy, and maintaining 99.9% type coverage.
Architecture Overview: The Core Challenge
The fundamental challenge: How do you store arbitrary user-defined data in a strongly-typed database while maintaining type safety in your PHP application?
The EAV Pattern (With Improvements)
We use an Entity-Attribute-Value (EAV) pattern, but with critical modifications for performance and type safety:
Schema::create('custom_field_values', function (Blueprint $table) { $table->id(); $table->morphs('entity'); // polymorphic: companies, people, opportunities $table->foreignId('custom_field_id')->constrained()->cascadeOnDelete(); // Multi-column storage for type safety $table->text('string_value')->nullable(); $table->longText('text_value')->nullable(); $table->boolean('boolean_value')->nullable(); $table->bigInteger('integer_value')->nullable(); $table->double('float_value')->nullable(); $table->date('date_value')->nullable(); $table->dateTime('datetime_value')->nullable(); $table->json('json_value')->nullable(); $table->unique(['entity_type', 'entity_id', 'custom_field_id']); });
Why multiple value columns instead of a single value column?
- Database-level type safety: PostgreSQL/MySQL validate types at insert
- Indexing: Can index specific types (e.g., date ranges, numeric ranges)
- Query performance: Filter by type without casting
- No serialization overhead: Direct storage, no JSON encode/decode for primitives
The Trade-off
Storage cost: ~7 nullable columns per row (mostly empty)
Gain: Type safety, indexability, query performance, no casting overhead
In production with 10K+ custom fields, the storage overhead is negligible (~2% database size) compared to the query performance gain (~40% faster than single-column EAV).
Implementation: The Field Type System
Trait-Based Composition Over Inheritance
Rather than abstract classes, we use traits for field type composition:
interface FieldTypeInterface { public function getDataType(): FieldDataType; public function getFormComponent(): Component; public function acceptsArbitraryValues(): bool; } trait HasCommonFieldProperties { public function isActive(): bool { return $this->active; } public function isSystemDefined(): bool { return $this->system_defined; } } // Example: Select field final class SelectFieldType implements FieldTypeInterface { use HasCommonFieldProperties; use HasImportExportDefaults; public function getDataType(): FieldDataType { return FieldDataType::STRING; } public function getFormComponent(): Component { return FormsComponentsSelect::make($this->code) ->options($this->getOptions()) ->live(); } public function acceptsArbitraryValues(): bool { return false; // Must be from predefined options } }
Why traits over inheritance?
- More flexible composition
- Avoids deep inheritance hierarchies
- Better for static analysis (PHPStan loves it)
- Easier to test in isolation
Dynamic Field Resolution
The magic happens in the model trait:
trait UsesCustomFields { public function customFieldValues(): MorphMany { return $this->morphMany(CustomFieldValue::class, 'entity') ->with('customField'); // Eager load definitions } public function getAttribute($key): mixed { // Check regular attributes first if (array_key_exists($key, $this->attributes)) { return parent::getAttribute($key); } // Check if it's a custom field $customField = $this->customFieldValues ->firstWhere('customField.code', $key); if ($customField) { return $this->resolveCustomFieldValue($customField); } return parent::getAttribute($key); } private function resolveCustomFieldValue(CustomFieldValue $value): mixed { return match ($value->customField->getDataType()) { FieldDataType::STRING => $value->string_value, FieldDataType::TEXT => $value->text_value, FieldDataType::BOOLEAN => $value->boolean_value, FieldDataType::INTEGER => $value->integer_value, FieldDataType::FLOAT => $value->float_value, FieldDataType::DATE => $value->date_value, FieldDataType::DATETIME => $value->datetime_value, FieldDataType::JSON => $value->json_value, }; } }
Now you can do:
$company = Company::find(1); echo $company->industry; // Regular attribute echo $company->annual_revenue; // Custom field - seamless access
Multi-Tenancy: Isolating Data at Scale
The Tenant Scoping Challenge
In a multi-tenant CRM, custom fields themselves must be tenant-scoped. Client A's "Contract Value" field shouldn't appear for Client B.
// In migration if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY)) { $table->foreignId('tenant_id')->nullable()->index(); $uniqueColumns[] = 'tenant_id'; } $table->unique($uniqueColumns); // ['entity_type', 'code', 'tenant_id']
Automatic Tenant Injection
Rather than manual scoping everywhere, we inject tenant context at the service layer:
final readonly class TenantContextService { public function getTenantId(): ?int { return Filament::getTenant()?->getKey(); } } // In CustomFieldsMigrator public function create(): CustomField { $data = $this->customFieldData->toArray(); if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY)) { $data['tenant_id'] = app(TenantContextService::class)->getTenantId(); } return CustomField::create($data); }
Key insight: Centralize tenant resolution in a service, not scattered across controllers/models.
Query Performance With Multi-Tenancy
The challenge: WHERE entity_type = ? AND entity_id = ? AND tenant_id = ? on every custom field lookup.
Solution: Composite indexes:
if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY)) { $table->index( ['tenant_id', 'entity_type', 'entity_id'], 'custom_field_values_tenant_entity_idx' ); }
Result: O(log n) lookups even with millions of custom field values.
Type Safety: Achieving 99.9% Coverage
The Static Analysis Strategy
// composer.json "scripts": { "test:types": "phpstan analyse", "test:type-coverage": "pest --type-coverage --min=99.9", "test": [ "@test:lint", "@test:refactor", "@test:types", "@test:type-coverage", "@test:pest" ] }
PHP 8.4 Features for Type Safety
#[ObservedBy(CustomFieldObserver::class)] final readonly class CustomField extends Model { use HasFactory; use SoftDeletes; protected $fillable = [ 'code', 'name', 'type', // ... ]; protected function casts(): array { return [ 'validation_rules' => 'array', 'settings' => 'array', 'active' => 'boolean', 'system_defined' => 'boolean', ]; } }
Key patterns:
finalclasses everywhere (prevent extension bugs)readonlywhere appropriate (immutability)#[Override]attribute for explicit overrides- Strict types in every file:
declare(strict_types=1);
Architecture Testing With Pest
arch('strict types') ->expect('App') ->toUseStrictTypes(); arch('avoid mutation') ->expect('App') ->classes() ->toBeReadonly() ->ignoring([ // Explicitly list mutable classes Company::class, People::class, ]); arch('final classes') ->expect('App') ->classes() ->toBeFinal();
This catches violations at CI time, not in production.
AI Integration: Claude API at Scale
The Context Building Challenge
When generating AI summaries, we need to provide relevant context without overwhelming the token limit.
final readonly class RecordContextBuilder { private const int RELATIONSHIP_LIMIT = 10; public function buildContext(Model $record): array { return match (true) { $record instanceof Company => $this->buildCompanyContext($record), $record instanceof People => $this->buildPeopleContext($record), $record instanceof Opportunity => $this->buildOpportunityContext($record), default => throw new InvalidArgumentException( 'Unsupported record type: ' . $record::class ), }; } private function buildCompanyContext(Company $company): array { // Eager load with limits to avoid N+1 and memory issues $company->load([ 'notes' => fn($q) => $q->latest()->limit(self::RELATIONSHIP_LIMIT), 'tasks' => fn($q) => $q->latest()->limit(self::RELATIONSHIP_LIMIT), 'customFieldValues.customField', ]); return [ 'entity_type' => 'Company', 'name' => $company->name, 'notes' => $this->formatNotes($company->notes), 'tasks' => $this->formatTasks($company->tasks), 'custom_fields' => $this->formatCustomFields($company), // ... ]; } }
Cost Optimization Through Caching
final readonly class RecordSummaryService { public function getSummary(Model $record, bool $regenerate = false): AiSummary { // Check cache first if (!$regenerate && method_exists($record, 'aiSummary')) { $cached = $record->aiSummary; if ($cached !== null) { return $cached; } } return $this->generateAndCacheSummary($record); } private function cacheSummary(Model $record, string $summary, Usage $usage): AiSummary { // Polymorphic relationship for caching return AiSummary::create([ 'summarizable_type' => $record->getMorphClass(), 'summarizable_id' => $record->getKey(), 'summary' => $summary, 'model_used' => 'claude-3-5-haiku-latest', 'prompt_tokens' => $usage->promptTokens, 'completion_tokens' => $usage->completionTokens, ]); } }
Real-world performance:
- Average summary: ~1,500 tokens
- Cost: ~$0.002 per summary
- Cache hit rate: ~85% (users regenerate rarely)
- Result: ~$5-10/month for active team vs. manual summary time saved
Performance Lessons
1. The N+1 Problem With Custom Fields
Bad:
$companies = Company::all(); foreach ($companies as $company) { echo $company->annual_revenue; // N+1 queries! }
Good:
$companies = Company::with('customFieldValues.customField')->get(); foreach ($companies as $company) { echo $company->annual_revenue; // No additional queries }
2. Polymorphic Query Optimization
Instead of:
$value = CustomFieldValue::where('entity_type', Company::class) ->where('entity_id', $companyId) ->where('custom_field_id', $fieldId) ->first();
Use composite indexes and IN queries:
$values = CustomFieldValue::whereIn('entity_id', $companyIds) ->where('entity_type', Company::class) ->get() ->groupBy('entity_id');
3. Memory Management for Bulk Operations
When importing 10K+ records with custom fields:
Company::chunk(100, function ($companies) { foreach ($companies as $company) { $this->processCustomFields($company); $company->unsetRelation('customFieldValues'); // Free memory } });
Real-World Metrics
After 6 months in production:
| Metric | Value |
|---|---|
| Total custom fields created | 12,847 |
| Total custom field values | 487,932 |
| Average query time (with eager loading) | 45ms |
| Database size overhead | ~2.1% |
| Type coverage | 99.9% |
| Failed deployments due to schema issues | 0 |
Conclusion: Was It Worth It?
Development cost: ~3 weeks to build initial system
Maintenance cost: ~2 hours/month (mostly adding new field types)
Time saved: ~10 hours/week (no more migration requests)
Deployment incidents prevented: Dozens (no schema coordination needed)
The architecture works because:
- EAV with type-specific columns provides the best of both worlds
- Multi-tenancy at the database level scales cleanly
- Strict type safety catches bugs at CI time
- Comprehensive testing prevents regressions
- AI integration adds value without complexity
Full Implementation
This architecture powers Relaticle, an open-source CRM built with Laravel 12. The custom fields system is extracted as a separate package: Custom Fields.
If you're building something similar:
- Start with a solid EAV foundation
- Invest in type safety early (PHPStan Level 5+)
- Use architecture tests to enforce patterns
- Profile queries religiously
- Cache aggressively
Questions? Happy to discuss the architecture decisions or specific implementation details in the comments.
Code Examples Available
- Custom Fields Package: https://github.com/Relaticle/custom-fields
- Full CRM Implementation: https://github.com/Relaticle/relaticle
- Documentation: https://custom-fields.relaticle.com
All code shown is production code from the open-source project.
submitted by /u/Local-Comparison-One
[link] [comments]