Building Dynamic Custom Fields in Laravel Without Database Migrations: A Deep Dive

How we solved the "just one more field" problem at scale

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?

  1. Database-level type safety: PostgreSQL/MySQL validate types at insert
  2. Indexing: Can index specific types (e.g., date ranges, numeric ranges)
  3. Query performance: Filter by type without casting
  4. 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:

  • final classes everywhere (prevent extension bugs)
  • readonly where 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:

  1. EAV with type-specific columns provides the best of both worlds
  2. Multi-tenancy at the database level scales cleanly
  3. Strict type safety catches bugs at CI time
  4. Comprehensive testing prevents regressions
  5. 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

All code shown is production code from the open-source project.

submitted by /u/Local-Comparison-One
[link] [comments]

Read more on Reddit Programming