Custom properties
Define custom properties, read and write their values on contacts, companies, and custom object records, query the change history, and create custom objects.
Every contact, company, and custom object record has a properties JSON column for fields that aren't built-in columns. PropertyDefinition records describe those fields, and every change to a record's columns or properties is written to PropertyHistory.
Property definitions
Odden\Core\Models\PropertyDefinition (table odden_properties) is a registry of the properties you expect on an entity type. The Filament package uses it to build forms and tables.
| Column | Notes |
|---|---|
entity_type |
Free-form string identifying the record type. Unique together with name. |
name |
The key inside properties. |
label |
Display label. |
type |
PropertyType enum, default text. |
group_name |
Default general. |
options |
JSON, cast to array. For example the choices of a select. |
description |
Nullable text. |
is_required, is_searchable |
Booleans, default false. |
sort_order |
Unsigned integer, default 0. |
use Odden\Core\Enums\PropertyType;
use Odden\Core\Models\Contact;
use Odden\Core\Models\PropertyDefinition;
PropertyDefinition::create([
'entity_type' => (new Contact)->getMorphClass(),
'name' => 'plan',
'label' => 'Plan',
'type' => PropertyType::Select,
'group_name' => 'billing',
'options' => ['choices' => ['starter', 'growth', 'enterprise']],
]);
$definitions = PropertyDefinition::forEntity(Contact::class)->get();The forEntity(string $entityType) scope filters by entity_type and orders by sort_order. Without a morph map, a model's morph class is its class name, so (new Contact)->getMorphClass() and Contact::class are the same value.
Core doesn't validate property values against definitions. setProperty() and setProperties() accept any key and value, and is_required, type, and options are not enforced. Validate input yourself, for example in a form request, before writing it.
Odden\Core\Enums\PropertyType cases. Each has a label().
| Case | Value | label() |
|---|---|---|
Text |
text |
Single-line Text |
Number |
number |
Number |
Boolean |
boolean |
Boolean (True/False) |
Select |
select |
Dropdown Select |
MultiSelect |
multi_select |
Multiple Checkboxes |
Date |
date |
Date Picker |
DateTime |
datetime |
Date and Time |
Json |
json |
JSON Object |
Reading and writing values
The HasCustomProperties trait casts properties to an array and adds these methods:
| Method | Behavior |
|---|---|
getProperty(string $name, mixed $default = null): mixed |
Reads with data_get(), so dot notation reaches into nested arrays. |
setProperty(string $name, mixed $value): static |
Sets one top-level key. Doesn't save. |
setProperties(array $properties): static |
Merges keys into the existing array with array_merge(). Doesn't save. |
whereProperty(string $name, mixed $value) scope |
where("properties->{$name}", $value). |
$contact->setProperty('plan', 'growth')->save();
$contact->setProperties(['seats' => 25, 'billing' => ['currency' => 'EUR']])->save();
$contact->getProperty('plan'); // "growth"
$contact->getProperty('billing.currency'); // "EUR"
$contact->getProperty('missing', 'n/a'); // "n/a"
Contact::whereProperty('plan', 'growth')->get();You can also pass a properties array when creating a record. It replaces the whole array, it doesn't merge.
Change history
The AuditsProperties trait listens to the Eloquent updating event. For each changed attribute it writes an Odden\Core\Models\PropertyHistory row (table odden_property_history):
| Column | Value |
|---|---|
auditable_type, auditable_id |
The record. |
property_name |
The column name. For the properties column, one row per changed key, named after the key. |
old_value, new_value |
Scalars cast to string, enums stored as their backing value, other values JSON-encoded. |
user_id |
auth()->id() at the time of the change, or null. |
source |
The request's X-Odden-Source header, defaulting to web. The default applies in queued jobs and console commands too. |
created_at |
now(). |
updated_at, deleted_at, and remember_token are never recorded. Creating a record writes no history.
$contact = Contact::create([
'email' => 'jane@acme.com',
'job_title' => 'Engineer',
'properties' => ['plan' => 'starter'],
]);
$contact->update(['job_title' => 'VP Engineering']);
$contact->setProperty('plan', 'growth')->save();
foreach ($contact->propertyHistory as $entry) {
echo "{$entry->property_name}: {$entry->old_value} → {$entry->new_value}";
}propertyHistory() is a MorphMany ordered newest first. Each entry has auditable() and user() relations.
History is only written for changes made through Eloquent model events. These changes write no history:
saveQuietly(),updateQuietly(), and query-builder updates such asContact::where(...)->update([...]).- lifecycle stage changes made with
TransitionLifecycleStageAction, which records them as lifecycle transitions instead. Contact::markContacted()and company enrichment.
To tag changes that come from an import or an integration, have the client send an X-Odden-Source header, such as X-Odden-Source: import, on the HTTP request that makes them. Changes made outside an HTTP request are always recorded as web.
Custom objects
Custom objects let you add your own record types, such as licenses or projects, without writing migrations. A CustomObjectDefinition describes the type, and each CustomObjectRecord stores its data in properties. Records use the same traits as contacts and companies: custom properties, change history, associations, activities, soft deletes, and the forTeam() scope. They don't have lifecycle stages.
use Odden\Core\Actions\CreateCustomObjectDefinitionAction;
use Odden\Core\Actions\CreateCustomObjectRecordAction;
$definition = app(CreateCustomObjectDefinitionAction::class)->execute([
'name' => 'Software License',
'singular_label' => 'License',
'primary_display_property' => 'license_key',
'secondary_display_properties' => ['seats', 'expires_on'],
'icon' => 'heroicon-o-key',
]);
$definition->name; // "software_license"
$definition->plural_label; // "Licenses"
$license = app(CreateCustomObjectRecordAction::class)->execute('software_license', [
'properties' => ['license_key' => 'ACME-2026-001', 'seats' => 50],
]);
$license->name; // "ACME-2026-001"
$license->getProperty('seats'); // 50
$company->associateWith($license, 'licensed');
$license->logNote('Renewal discussed.');CreateCustomObjectDefinitionAction::execute(array $attributes): CustomObjectDefinition:
- converts
nameto a snake-case slug. Names are unique. - derives
plural_labelfromsingular_labelwhen you don't pass one. - dispatches
CustomObjectDefinitionCreated.
primary_display_property defaults to name.
CreateCustomObjectRecordAction::execute(CustomObjectDefinition|string $definition, array $attributes): CustomObjectRecord:
- accepts a definition model or its
name. An unknown name throwsInvalidArgumentException. - sets
definition_id. - when
nameis empty, uses the value of the definition'sprimary_display_propertyfromproperties, or falls back to"{singular_label} #<uniqid>". - dispatches
CustomObjectRecordCreated.
$definition->records() returns the records of a type, and $record->definition returns its definition. Records also have an owner() relation to your user model. Deleting a definition deletes its records (cascade at the database level).
You can attach PropertyDefinition rows to a custom object type by choosing an entity_type value for it, but Core doesn't link the two.