Looking to hire Laravel developers? Try LaraJobs

laravel-custom-fields maintained by byrcsc

Description
Typed, filterable custom fields for any Eloquent model, with validation, defaults, and optional tenant scoping—no schema changes per field.
Author
Last update
2026/08/26 07:08 (dev-main)
License
Downloads
1

Comments
comments powered by Disqus

Laravel Custom Fields

Latest Version on Packagist GitHub Tests Action Status GitHub PHPStan Action Status Total Downloads

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.x is 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.