Let the users of your product add typed fields to an Eloquent model at runtime, such as a
patient's risk level or a project's budget, without writing a migration for each one. Every
value lands in a typed column, is validated by the type of its field, and can be filtered
and sorted from a request through spatie/laravel-query-builder, as in
?filter[cf_risk-level]=high.
The package is headless. It ships no UI, no routes and no authorization; your application owns those.
If your models use UUID or ULID keys, publish the config first and set morph_key_type
before you migrate, see
installation.
composer require plin-code/laravel-custom-fields
php artisan vendor:publish --tag="laravel-custom-fields-migrations"
php artisan migrateuse Illuminate\Database\Eloquent\Model;
use PlinCode\CustomFields\Concerns\HasCustomFields;
use PlinCode\CustomFields\Facades\CustomFields;
use PlinCode\CustomFields\Models\CustomField;
use Spatie\QueryBuilder\QueryBuilder;
// app/Models/Patient.php
class Patient extends Model
{
use HasCustomFields;
}
// In the boot() method of a service provider, on every request.
CustomFields::registerEntity(Patient::class, 'patient');
// Wherever your product lets an administrator define a field.
CustomField::create([
'entity_type' => CustomFields::entityKey(Patient::class),
'name' => 'Risk level',
'type' => 'select',
'options' => [
['key' => 'low', 'label' => 'Low', 'is_active' => true],
['key' => 'high', 'label' => 'High', 'is_active' => true],
],
]);
$patient->setCustomFields(['risk-level' => 'high']);
$patient->getCustomFields(); // ['risk-level' => 'high']
// In a controller serving GET /patients?filter[cf_risk-level]=high
$options = CustomFields::queryOptionsFor(Patient::class);
$patients = QueryBuilder::for(Patient::class)
->allowedFilters(...$options['filters'])
->allowedSorts(...$options['sorts'])
->get();The slug risk-level is generated once from the name and never changes. It is the key you
read, write, filter and sort by.
Three patients, the risk-level field above, a date-of-birth field of type date, an
allergies field of type multiselect with the options pollen and latex, and the
values their records hold:
| Patient | risk-level |
date-of-birth |
allergies |
|---|---|---|---|
| Anna Rossi | high |
1981-04-02 | ['pollen'] |
| Bruno Neri | high |
1974-11-20 | ['pollen', 'latex'] |
| Carla Verdi | low |
1990-06-30 | no value |
One controller serves all of it:
use App\Models\Patient;
use PlinCode\CustomFields\Facades\CustomFields;
use Spatie\QueryBuilder\QueryBuilder;
public function index()
{
$options = CustomFields::queryOptionsFor(Patient::class);
return QueryBuilder::for(Patient::class)
->allowedFilters(...$options['filters'])
->allowedSorts(...$options['sorts'])
->paginate();
}What the client sends, and what comes back:
| Request | Result |
|---|---|
?filter[cf_risk-level]=high |
Anna Rossi, Bruno Neri |
?filter[cf_allergies:contains_any]=latex |
Bruno Neri |
?filter[cf_allergies:contains_all]=pollen,latex |
Bruno Neri |
?filter[cf_date-of-birth:between]=1980-01-01,1995-12-31 |
Anna Rossi, Carla Verdi |
?filter[cf_allergies:is_null]=1 |
Carla Verdi |
?filter[cf_risk-level]=high&sort=cf_date-of-birth |
Bruno Neri, Anna Rossi |
?sort=-cf_date-of-birth |
Carla Verdi, Anna Rossi, Bruno Neri |
Two rows are worth reading twice. contains_any=latex returns only Bruno because Anna
holds pollen alone, and is_null=1 returns Carla because she has no allergies row at
all. The last row sorts every patient by date of birth descending, and nobody is dropped
for lacking a value.
A field only exposes the operations its type declares, so nothing arbitrary can be built from a request. The ten operations and their rules are in querying.
A JSON column on the host table, for example with
spatie/laravel-schemaless-attributes,
is simpler and is the right choice when you only store and read the values back. This
package stores one row per field per record, in a column typed after the field. That buys
filtering and sorting that compare integers as integers and dates as dates, one query API
that runs on SQLite, MySQL and PostgreSQL, and option keys that stay stable while labels
change. The cost is a whereHas subquery per filter and a correlated subquery per sort,
and contains is a LIKE %x% that no index can serve.
- PHP ^8.4.
- Laravel 12 or 13.
spatie/laravel-query-builder, which provides theAllowedFilterandAllowedSortinstances the package builds.plin-code/laravel-eloquent-sorts, which validates the sort direction throughDirection::normalise()and gives you relation, relation count and enum sorts that compose with the custom field sorts.plin-code/laravel-sql-dialect, which builds thecontainsmatch asILIKEon PostgreSQL andLIKEelsewhere, with the wildcards of the term escaped.spatie/laravel-package-tools, which wires the config, the translations and the migrations.
All four are installed with the package. CI runs the suite on PHP 8.4 and 8.5 against SQLite, and on PHP 8.4 against MySQL 8.4 and PostgreSQL 17.
- Installation and configuration: publishing, migrations, key types, translations, every config key, replacing the models.
- Defining fields: registering an entity, the twelve field types, slugs, options, deactivating a field.
- Reading and writing values: the value API, partial and complete validation, the rules of each type, exceptions, forms.
- Querying:
the
whereCustomField()scope, request filters and sorts, the operations, invalid values. - Custom field types:
the
FieldTypecontract, events, and the Laravel Boost resources. - Upgrading: behaviour changes from 0.3.0 to 1.0.0 for an application on 0.1 or 0.2.
CustomFieldandCustomFieldValuedeclare$fillable, so an unknown key increate()is discarded (or throws withModel::preventSilentlyDiscardingAttributes()). Validating the values and authorizing the request still belong to your application.- Authorization is your job. Nothing checks who may define, read or write a field.
registerEntity()adds the key toRelation::morphMap(), so it becomes the model'sgetMorphClass()for every polymorphic relation, not only for custom fields.- The shipped migration indexes
value_string,value_boolean,value_integer,value_decimal,value_dateandvalue_datetime, each together with the field. An install that ran an earlier migration adds the four newer ones, see upgrading.value_textandvalue_jsonare not indexed. - Deleting a host model deletes its values, but a bulk delete through the query builder fires no model events and leaves them behind.
- An invalid filter value throws
PlinCode\CustomFields\Exceptions\InvalidFilterValueException, a SpatieInvalidFilterValuethat Laravel renders as a 400 out of the box. - Stored values are not encrypted.
- On SQLite a
decimalhas floating point affinity, so very long values lose precision.
composer testThat runs PHPStan, the Pint check, the Rector dry run, the type coverage check and the Pest
suite. The suite uses in memory SQLite by default; DB_DRIVER=mysql or DB_DRIVER=pgsql
points it at a server.
Please see the changelog for what has changed recently.
Thank you for considering contributing to Laravel Custom Fields! Please review our contributing guide to get started.
Please review our security policy on how to report security vulnerabilities.
Laravel Custom Fields is open-sourced software licensed under the MIT license.
