byrcsc / laravel-custom-fields
Typed, filterable custom fields for any Eloquent model, with validation, defaults, and optional tenant scoping—no schema changes per field.
Fund package maintenance!
Requires
- php: ^8.3
- illuminate/console: ^12.0||^13.0
- illuminate/contracts: ^12.0||^13.0
- illuminate/database: ^12.0||^13.0
- illuminate/support: ^12.0||^13.0
- illuminate/validation: ^12.0||^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.3.1
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^10.0||^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- spatie/laravel-multitenancy: ^4.2
Suggests
- spatie/laravel-multitenancy: Ships a bundled tenant resolver (^4.0). Install it and tenant scoping configures itself.
README
Add typed, filterable custom fields to any Eloquent model without changing its table. Use one global field set in a single-tenant application, or scope fields and values per tenant when each tenant needs different customer details.
Definitions live in one table and values in a typed polymorphic second table. Models get a trait exposing read and write methods, query scopes, generated validation rules, and virtual defaults.
The package is developer-facing infrastructure. Which fields exist, what they validate, and who may write them stays yours, and so does any admin UI a tenant would use to manage them.
| Laravel | Tested PHP versions |
|---|---|
| 12.x | 8.3, 8.4 |
| 13.x | 8.3, 8.4 |
Documentation
Read the official documentation for installation, field definitions, reading and writing values, validation, filtering, tenant scoping, performance, and troubleshooting.
Installation
Install the package via composer:
composer require byrcsc/laravel-custom-fields
Publish and run the migrations:
php artisan vendor:publish --tag=custom-fields-migrations php artisan migrate
Publish the config file if you need to change anything in it:
php artisan vendor:publish --tag=custom-fields-config
What a custom field is
A custom field is a definition a developer creates at runtime: a key, a type, optional validation rules, and an optional tenant. Records store values against those definitions in typed columns, so a number filters as a number and a date is stored as a date. A definition with no tenant is global and visible to every tenant; on a key collision the tenant's own field wins.
Two tables hold all of it. custom_field_definitions has one row per field
per model class per tenant. custom_field_values has one row per field per
record per tenant, with the value in whichever typed column its type names.
Your own tables never change.
Quick start
Add the trait to any model:
use ByRcsc\LaravelCustomFields\Concerns\HasCustomFields; use Illuminate\Database\Eloquent\Model; class Customer extends Model { use HasCustomFields; }
Define a field:
use ByRcsc\LaravelCustomFields\Enums\FieldType; use ByRcsc\LaravelCustomFields\Facades\CustomFields; CustomFields::define(Customer::class, [ 'key' => 'account_tier', 'type' => FieldType::Select, 'options' => ['standard', 'premium'], 'label' => 'Account tier', 'default' => 'standard', 'rules' => ['required' => true], ]);
Write and read it:
$customer = Customer::query()->findOrFail(1); $customer->setCustomField('account_tier', 'premium'); $customer->getCustomField('account_tier'); // 'premium' $customer->customFields; // Collection: ['account_tier' => 'premium']
Filter by it:
Customer::query()->whereCustomField('account_tier', 'premium')->get();
A customer that never set the field reads the definition's default, so
getCustomField('account_tier') returns standard without a value row existing.
Defining fields
define() takes key and type, and optionally label, description,
default, options, rules, section, sort_order, and tenant.
A key is lowercase, starts with a letter, and continues with letters, digits, or underscores, up to 64 characters. Anything the facade cannot honour throws on the line that wrote it: an unknown type, an unknown attribute name, a select without options, a rule it will never run.
The eleven types are text, textarea, email, url, number, boolean,
date, datetime, select, multi_select, and json. Each is stored in
the column its type names, and read back as its native PHP type: a number as
int or float, a date as a Carbon instance, a multi-select as a list of
strings.
section and sort_order are metadata. The package stores them, returns
them, and attaches no behaviour to them; grouping and ordering fields on a
screen is yours.
Definitions are queryable through CustomFieldDefinition and writable only
through the facade, which is where the checks above live. The model guards
every attribute against mass assignment to keep it that way.
Tenant scoping
Which tenant the package is acting for comes from a resolver:
use ByRcsc\LaravelCustomFields\Contracts\TenantResolver; final class SubdomainTenantResolver implements TenantResolver { public function currentTenant(): ?string { return request()->route('tenant'); } }
Point custom-fields.tenant_resolver at it. The key defaults to null, which
means detect: applications with spatie/laravel-multitenancy installed get
the bundled SpatieTenantResolver and configure nothing, and applications
without it get NullTenantResolver, under which every field is global. A
spatie application that wants one shared set of fields rather than a set per
tenant names NullTenantResolver here explicitly.
Null is a real answer, not a failure. It is what a single-tenant application returns always, and what a multi-tenant one returns from a console command. Definitions written under it are global.
Passing 'tenant' => 'acme' to define() writes a field for a tenant you are
not currently acting for, which is what a seeder or a console command needs.
Passing 'tenant' => null writes a global field while a tenant is current.
Reading and writing values
$article->setCustomField('priority', 'high'); $article->setCustomFields(['priority' => 'high', 'reading_minutes' => 9]); $article->getCustomField('priority'); $article->customFields; // every visible field, keyed by key
customFields is an Illuminate\Support\Collection, so ->all() gives you
the plain array and everything else a collection does is available on it.
Global definitions are shared, but their values are not: the same record can
hold a different value for each tenant.
Writes persist immediately. setCustomFields() resolves every key before
writing anything, so a call naming one unknown field writes none of them.
Writing null clears a field, which returns it to the definition's default. There is no third state where a record holds an explicit null that outranks the default.
Reading or writing a key the current tenant has no definition for throws
UnknownCustomFieldException, on reads as well as writes. A typo that
quietly returned null would read exactly like a field nobody has filled in.
Defaults are virtual: they are resolved when you read, never written as rows. Changing a definition's default changes what every unset record returns, and that is the documented behaviour rather than a side effect.
Validation
The generator hands a form request a ready rules array:
final class StoreArticleRequest extends FormRequest { public function rules(): array { return CustomFields::rulesFor(Article::class, prefix: 'custom_fields'); } public function attributes(): array { return CustomFields::attributeNamesFor( Article::class, prefix: 'custom_fields', ); } }
prefix nests the keys for a form that posts its custom fields under one
name. Pass the record being updated as ignoring: so a unique rule does not
read its own stored value as a duplicate.
The same rules run on write. setCustomField() throws Laravel's own
ValidationException on a value that breaks them, which
custom-fields.validate_on_write turns off for an application that would
rather validate every write itself.
Per-field rules are required, min, max, regex, and unique, given as
a map:
'rules' => ['required' => true, 'min' => 1, 'max' => 120],
unique means unique for that field, under that tenant, on that model type.
It is advisory. It reads and then writes with no lock between, so two
concurrent requests can both pass it and both insert. Enforcing it properly
would need a partial unique index across one of six columns chosen by a value
in another table, which no supported engine expresses. It is refused on
multi_select and json, whose whole-value comparison is not portable.
Filtering
Article::query()->whereCustomField('priority', 'high'); Article::query()->whereCustomFieldIn('priority', ['low', 'high']);
Both compile to a comparison on one typed column inside an exists clause, and both are scoped to the field the current tenant can see.
Filtering a multi_select means contains: pass one option, and it matches
every record whose list holds it.
Article::query()->whereCustomField('tags', 'news');
Two limits worth knowing. whereCustomFieldIn is for single-value types;
multi-select is a list already, so use the contains form above. And json
cannot be filtered by equality at all, because comparing a whole JSON value
differs across MySQL, PostgreSQL, and SQLite down to key order and
whitespace. Both throw UnfilterableFieldException rather than returning
different rows on different engines.
string_value, number_value, boolean_value, and date_value are
indexed alongside the field id, so those filters use an index. text_value
and json_value are not: no portable index covers a column wide enough to
hold them, so filtering a textarea or a multi_select scans the values for
that field.
Eager loading
Reading custom fields on a listing costs one extra query for the whole page, not one per record, as long as you eager load the relation:
Article::query()->with('customFieldValues')->get();
The definitions themselves are resolved once per model class and tenant for the life of the request, so they do not become the N+1 that the eager-loaded values no longer are.
Definition lifecycle
Three guarantees, all of them about not losing data quietly:
A type change is refused while values exist. Each type lives in its own
column, so changing a type points the field at a column its values are not
in. CannotChangeFieldTypeException names the field, both types, and how
many values are in the way. Migrate or clear them, then change the type.
A stored option that was later removed keeps being returned. It was valid when it was written. Validation applies on the next write, so writing it again is refused, but reading it back is not.
Deleting a definition leaves its values alone. There is no foreign key and no cascade, so removing a field by mistake does not take the data with it. When the removal was meant, one command takes the orphans:
php artisan custom-fields:prune # delete values whose field is gone php artisan custom-fields:prune --dry-run # report what would go
Prune deletes only orphaned values, one row at a time so the value model's delete events fire, and reports how many it took.
The type guard hangs on the definition model's updating event, so it sees a
change made through the model, which is the intended path. It cannot see a
saveQuietly(), a write inside withoutEvents(), or a mass
query()->update(), because none of those fire a model event. The same is
true of the resolved-definitions cache; call CustomFields::flush() after a
write of that kind.
Events
The definition and value models fire Laravel's standard Eloquent events:
creating, created, updating, updated, saving, saved, deleting,
and deleted. There are no dedicated event classes, so hook the models:
use ByRcsc\LaravelCustomFields\Models\CustomFieldValue; CustomFieldValue::saved(function (CustomFieldValue $value): void { // reindex the record this value belongs to });
Configuration
| Key | Default | What it does |
|---|---|---|
tenant_resolver |
null |
The resolver class. Null means detect. |
tables.definitions |
custom_field_definitions |
Definitions table name. |
tables.values |
custom_field_values |
Values table name. |
model_key_type |
int |
Shape of model_id: int, uuid, ulid, or string. |
validate_on_write |
true |
Whether the trait validates before writing. |
model_key_type and validate_on_write read CUSTOM_FIELDS_MODEL_KEY_TYPE
and CUSTOM_FIELDS_VALIDATE_ON_WRITE from the environment.
Set the table names and the key type before migrating. Changing them afterwards takes a migration of your own.
Out of scope
Deliberate boundaries rather than gaps:
- Any UI. Permanently, not just for now. The admin screen a tenant would use to manage fields sits on top of the package.
- File and relation field types. Files drag in disk storage and lifecycle, relations drag in referential integrity and cross-tenant leakage. Both are a different weight class.
- Dedicated package events. The definition and value models fire standard Eloquent events.
- Sorting by custom field value. Parked rather than ruled out: it reopens if index pages need sortable columns backed by custom fields.
- A type-migration command. Parked too, and the pressure valve for the refused type change. It reopens the first time that refusal blocks real work. Writing the migration yourself is a loop over the values.
- Auditing, import and export, and conditional field logic.
Versioning
The package follows semantic versioning.
- Upgrading within
1.xis safe. Nothing you use will break. - Only a new major version, like
2.0.0, can break your code. - If the README or the documentation describes it, it is safe to build on. If they don't, treat it as internal and expect it to change.
Bug fixes go into the newest version only. To get a fix, upgrade to it.
Questions and issues
- Stuck, or have an idea? Start a discussion. Usage questions and feature ideas both live there.
- Found a bug you can reproduce? Open an issue. A failing test is the fastest way to a fix, and a short reproduction is the next best thing.
- Found a security problem? Please don't open a public issue. See SECURITY.md for how to report it privately.
- Planning a pull request? CONTRIBUTING.md covers the setup and the three checks it needs to pass.
This package is maintained by one person, so replies can take a while. Everything gets read.
License
MIT. See LICENSE.md. Changelog in CHANGELOG.md.