skylence/laravel-custom-fields
Laravel Custom Fields adds dynamic, user-defined fields to Eloquent models without schema changes. Define field types, attach values to records, and manage validation and storage through a flexible, configurable API for building customizable apps and admin panels.
Install and publish everything first.
composer require skylence/laravel-custom-fields
php artisan vendor:publish --provider="Skylence\CustomFields\CustomFieldsServiceProvider"
php artisan migrate
There are typically two things to publish: the config (config/custom-fields.php) and the migrations (the custom_fields definition table and the custom_field_values / polymorphic value table). Publish both before migrating — the migrations create the schema the package's models expect, and re-running them after changing the morph key is a headache.
Where to look in the repo, in order:
README.md — the package is low-traffic (0 dependents), so the README is your only reliable contract. Read it fully before guessing method names.src/Concerns/ or src/Traits/ — find the trait you add to your models (usually HasCustomFields). This defines the public API surface: getter, setter, relationship name.src/Models/CustomField.php — shows the field definition columns (key, type, label, options, rules, etc.).src/Filament/ — the Filament form/table components, which is the package's main selling point.config/custom-fields.php — table names, morph map key, cache TTL, and the list of supported field types.First real use case — add fields to a model without touching its migration:
use Skylence\CustomFields\Concerns\HasCustomFields;
class Customer extends Model
{
use HasCustomFields;
}
Then create a field definition (the exact API may be a Filament resource, an artisan command, or a model factory — check the README):
CustomField::create([
'model_type' => Customer::class,
'key' => 'vat_number',
'type' => 'text',
'label' => 'VAT Number',
]);
And read/write it:
$customer->setCustomField('vat_number', 'BE0123456789');
$customer->getCustomField('vat_number'); // 'BE0123456789'
That's the whole loop. Everything else is wiring it into Filament and deciding how you query it.
The core mental model: field definitions live in rows, values live in rows. You never run a migration to add a customer attribute again. Treat CustomField records as production data — seed them per environment, version them in a seeder, and never hand-create them in prod without a matching seeder.
// database/seeders/CustomFieldSeeder.php
foreach ([
['key' => 'vat_number', 'type' => 'text', 'label' => 'VAT Number'],
['key' => 'credit_limit', 'type' => 'number', 'label' => 'Credit Limit'],
['key' => 'segment', 'type' => 'select', 'label' => 'Segment',
'options' => ['smb' => 'SMB', 'enterprise' => 'Enterprise']],
] as $field) {
CustomField::updateOrCreate(
['model_type' => Customer::class, 'key' => $field['key']],
$field + ['model_type' => Customer::class]
);
}
This is why you'd pick this package over hand-rolling. The typical pattern is a repeater-style section on the resource form that renders one input per applicable field:
use Skylence\CustomFields\Filament\Forms\Components\CustomFieldsSection;
use Skylence\CustomFields\Filament\Tables\Columns\CustomFieldsColumn;
public static function form(Form $form): Form
{
return $form->schema([
TextInput::make('name'),
CustomFieldsSection::make()->forModel(Customer::class),
]);
}
public static function table(Table $table): Table
{
return $table->columns([
TextColumn::make('name'),
CustomFieldsColumn::make('custom_fields'),
]);
}
The section should hydrate from custom_field_values on edit and persist on save via the trait. If it doesn't persist automatically, that's your signal to look at the trait's save hooks — some implementations require a saveCustomFields() call in the resource's afterCreate/afterSave.
The values are almost certainly a polymorphic morphMany. Load them up front in table queries:
Customer::with('customFieldValues')->get();
Then, if the package doesn't already cache definitions per request, wrap your own accessor:
protected array $fieldCache = [];
public function field(string $key): mixed
{
return $this->fieldCache[$key] ??= $this->getCustomField($key);
}
There's no reliable generic query builder here — filtering on EAV values is a whereHas:
Customer::whereHas('customFieldValues', function ($q) {
$q->where('key', 'segment')->where('value', 'enterprise');
})->get();
If you find yourself doing this often for one field, stop: promote that field to a real column. EAV is for the long tail of rarely-queried attributes, not for anything you put in a WHERE clause or sort by.
Store rules on the field definition and apply them dynamically, so the Filament form and any API both enforce the same contract:
$rules = CustomField::where('model_type', Customer::class)
->pluck('rules', 'key')
->map(fn ($r) => $r ? explode('|', $r) : ['nullable'])
->toArray();
$request->validate(collect($rules)->mapWithKeys(fn ($r, $k) => ["custom_fields.$k" => $r])->all());
Keep custom fields out of $appends on the model unless you eager-load. Expose them explicitly:
return response()->json([
'id' => $customer->id,
'name' => $customer->name,
'meta' => $customer->customFieldValues
->pluck('value', 'key'),
]);
The morph key is the trap. If the config uses a morph alias (customer instead of App\Models\Customer), then model_type in custom_fields must match the same alias. Mismatched morph maps are the single most common cause of "the field exists but the value won't save" — silently, with no error. Pick one convention at install time: set Relation::enforceMorphMap() in a service provider, or always store FQCNs. Don't mix.
Cache field definitions, but invalidate on write. Packages like this usually cache the definition list per model type. If you create a field at runtime through a Filament admin resource and it doesn't appear, the cache is stale. Look for a cache key in the config and either call the package's clear method or hook into CustomField::saved / deleted:
CustomField::saved(fn () => Cache::forget('custom-fields:'.Customer::class));
Orphaned values. Deleting a CustomField definition does not necessarily delete its custom_field_values. Either add a booted() cascade on the package model or run a periodic cleanup. Leaving them is mostly harmless until you reuse the same key with a different type and old string values get cast to integers.
Type casting is on you. EAV values come back as strings (or possibly JSON). number, boolean, and date types need explicit coercion in your accessor. Never === a getCustomField('credit_limit') result against 0 — compare loosely or cast first.
Filament panel registration. Some versions require registering a plugin in PanelProvider::boot():
->plugin(\Skylence\CustomFields\Filament\CustomFieldsPlugin::make())
If the form section renders but the type-specific inputs (selects, dates) all appear as text inputs, the plugin isn't registered and the type→component mapping isn't loaded.
Migration order matters on multi-tenant setups. The value table usually has a unique index on (model_type, model_id, key) or a polymorphic index. In multi-tenant databases, verify the index includes your tenant column if you shard — otherwise upserts collide across tenants.
Testing. Write a single test that exercises the full round-trip: create definition → save value via model → assert read-back → assert Filament form hydrates. The package is low-traffic enough that regressions aren't caught upstream; your test suite is the compatibility contract.
Extension point: the type→component and type→cast maps are nearly always published in config. Register a custom type there rather than forking — e.g. a money type with a currency select. This is the cleanest place to extend the package without fighting updates.
Reality check on maintenance. Zero stars and zero dependents means no community safety net. Before adopting, read composer.json for the Laravel and Filament version constraints and confirm both support the versions you run. Check the src/ directory size — if the Filament components are thin wrappers, you may be better served copying the trait and two components into your app and owning them outright. The EAV pattern here is ~200 lines; vendor lock-in is the only real cost.
How can I help you explore Laravel packages today?