asseco-voice/laravel-custom-fields
Extend Laravel Eloquent models with user-defined custom fields, no schema changes needed. Define fields per model, store values flexibly, and use them in forms, validation, and queries—a simple way to let apps add their own attributes dynamically.
Install and inspect before writing any code — the migration is the API contract for this package:
composer require asseco-voice/laravel-custom-fields
php artisan vendor:publish --provider="AssecoVoice\CustomFields\CustomFieldsServiceProvider"
php artisan migrate
Read the published migration first. Everything you need to know about how the package stores data (model_type/model_id morphs, the field key column, the value column, and — as of v3.3.1 — a renderer column) is there, and the trait's method names follow from it. Then attach the trait to any model that needs user-defined attributes:
use AssecoVoice\CustomFields\Traits\HasCustomFields;
class Product extends Model
{
use HasCustomFields;
}
First use case: let an admin add "Internal SKU", "Warranty period" or "Country of origin" to Product without a schema migration or a deploy. That is the whole value proposition — treat the package as a key/value sidecar, not as a replacement for real columns. Anything you filter, sort, or join on heavily should still be a real column; anything editorial and sparse belongs in custom fields.
Trait + morph map. Register a morph map before you ship, or your rows will be tied to FQCNs and break the day someone moves a class into App\Domain\...:
Relation::enforceMorphMap(['product' => Product::class, 'customer' => Customer::class]);
Read as a keyed array, not a collection. The relationship returns rows; views and API resources almost always want key => value:
$product->load('customFields');
$fields = $product->customFields->pluck('value', 'field_name');
$fields->get('warranty_period');
Eager load with with('customFields') on any index/list endpoint — this is a polymorphic relation, so it will N+1 silently otherwise.
Drive forms from the renderer column (the v3.3.1 addition). The new column lets each field declare how it should be presented, so the frontend stops hardcoding "everything is a text input":
// config/custom-fields.php — your own whitelist, not the DB's
'renderers' => [
'text' => \App\View\Components\Fields\Text::class,
'number' => \App\View\Components\Fields\Number::class,
'select' => \App\View\Components\Fields\Select::class,
'date' => \App\View\Components\Fields\Date::class,
],
$product->customFields->map(fn ($f) => [
'key' => $f->field_name,
'value' => $f->value,
'renderer' => $f->renderer ?? 'text', // backfill nulls in code first
]);
Resolve the renderer through your own map and fall back to text on anything unknown — never to a value straight from the database.
Validate and cast on the way out. The package stores what you give it, as strings. Put the typing in a FormRequest and a cast layer:
'warranty_period' => ['nullable', 'integer', 'min:0', 'max:120'],
'country' => ['nullable', Rule::in(array_keys(config('countries')))],
$fields->put('is_fragile', filter_var($fields->get('is_fragile'), FILTER_VALIDATE_BOOLEAN));
Define field schemas in config, sync into the DB. For anything beyond a handful of fields, keep the canonical list in config/custom-fields.php per model type and write an artisan command that upserts rows and deletes removed keys. Hand-editing rows in production is how you end up with Warranty_Period and warranty_period both live.
Filtering. whereHas('customFields', fn ($q) => $q->where('field_name', 'country')->where('value', 'DE')) works, but only add an index on (field_name, value) if you actually need it — value columns are not indexed by the package and this is the query that will fall over first at scale.
renderer column only lands if you run the package migrations. If you published and hand-edited the migration earlier, vendor:publish will not overwrite it — add the column yourself as nullable()->default('text'), then backfill existing rows. Treat null as text in every read path so pre-3.3.1 data keeps working.->create(), check the custom field model's $fillable; a silently dropped renderer attribute is the most likely first bug you hit with this release."0" is a valid value and falsy. empty(), filter(), and ?? will all bite you. Use !== null and !== '' checks when reading field values.Product orphans its custom fields. Add a deleting observer to clean up.{!! !!} block.tenant_id on the table, or admins will see each other's field definitions.$product->customFields()->toSql() and DB::listen() will tell you more than the exception handler here, because most failures are "the row was never written" rather than "the package threw."How can I help you explore Laravel packages today?