Overview
Custom fields allow you to add additional data to content types in Digital Shopfront CMS. Unlike traditional meta tables, custom fields are stored as actual database columns for better performance and queryability.
Table of Contents
- Understanding Custom Fields
- Available Field Types
- Registering Custom Fields
- Column Types
- Migration Generation
- Retrieving and Displaying Values
- Applying Values From Untrusted Input
- Data Migration
- Best Practices
Understanding Custom Fields
Column-Based Storage
Digital Shopfront CMS stores custom fields as actual database columns, not in a meta table:
Traditional Approach (Meta Table):
posts table: id, title, content
meta table: post_id, meta_key, meta_value
Digital Shopfront CMS Approach (Columns):
posts table: id, title, content, rating, price, featured
Benefits
- Better Performance: Direct column access, no joins required
- Type Safety: Database-level type enforcement
- Indexable: Can create indexes on custom fields
- Queryable: Standard SQL queries work
- Simpler Code: Access fields as model properties
Available Field Types
v1.1.0: Field types and column types are now backed by the
FieldTypeandColumnTypeenums for type safety. You can use either string values (for backwards compatibility) or enum instances. See developer/enums for the complete reference.
Text Field
Single-line text input.
use ArtisanPackUI\CMSFramework\Modules\ContentTypes\Enums\FieldType;
use ArtisanPackUI\CMSFramework\Modules\ContentTypes\Enums\ColumnType;
[
'type' => FieldType::Text, // or 'text'
'column_type' => ColumnType::String, // or 'string'
]
Use Cases: Names, titles, short descriptions, URLs
Textarea Field
Multi-line text input.
[
'type' => 'textarea',
'column_type' => 'text',
]
Use Cases: Long descriptions, notes, addresses
Number Field
Numeric input.
[
'type' => 'number',
'column_type' => 'integer', // or 'decimal', 'float'
]
Use Cases: Quantities, ratings, prices
Boolean Field
True/false toggle.
[
'type' => 'boolean',
'column_type' => 'boolean',
]
Use Cases: Featured flag, published status, enabled/disabled
Date Field
Date picker.
[
'type' => 'date',
'column_type' => 'date', // or 'datetime', 'timestamp'
]
Use Cases: Event dates, deadlines, publish dates
Select Field
Dropdown selection.
[
'type' => 'select',
'column_type' => 'string',
'options' => [
'choices' => ['Option 1', 'Option 2', 'Option 3'],
],
]
Use Cases: Status, priority, type selection
Checkbox Field
Multiple selections.
[
'type' => 'checkbox',
'column_type' => 'json',
'options' => [
'choices' => ['Feature 1', 'Feature 2', 'Feature 3'],
],
]
Use Cases: Multiple options, feature flags
Registering Custom Fields
Via Service Provider
<?php
namespace App\Providers;
use ArtisanPackUI\CMSFramework\Modules\ContentTypes\Managers\CustomFieldManager;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
$customFieldManager = app(CustomFieldManager::class);
$customFieldManager->registerField([
'name' => 'Price',
'key' => 'price',
'type' => 'number',
'column_type' => 'decimal',
'description' => 'Product price in USD',
'content_types' => ['products'],
'options' => [
'precision' => 10,
'scale' => 2,
],
'order' => 1,
'required' => true,
'default_value' => '0.00',
]);
}
}
Via Filter Hook
addFilter('ap.contentTypes.registeredCustomFields', function ($fields) {
$fields['rating'] = [
'name' => 'Rating',
'key' => 'rating',
'type' => 'number',
'column_type' => 'integer',
'content_types' => ['products', 'reviews'],
'options' => [
'min' => 1,
'max' => 5,
],
'required' => false,
'default_value' => 3,
];
return $fields;
});
Via Database/API
use ArtisanPackUI\CMSFramework\Modules\ContentTypes\Models\CustomField;
$field = CustomField::create([
'name' => 'SKU',
'key' => 'sku',
'type' => 'text',
'column_type' => 'string',
'content_types' => ['products'],
'order' => 1,
'required' => true,
]);
Column Types
String
For short text (up to 255 characters).
'column_type' => 'string'
Migration:
$table->string('field_name')->nullable();
Text
For long text (up to ~65,000 characters).
'column_type' => 'text'
Migration:
$table->text('field_name')->nullable();
Integer
For whole numbers.
'column_type' => 'integer'
Migration:
$table->integer('field_name')->nullable();
Decimal
For precise decimal numbers.
'column_type' => 'decimal'
'options' => ['precision' => 10, 'scale' => 2]
Migration:
$table->decimal('field_name', 10, 2)->nullable();
Boolean
For true/false values.
'column_type' => 'boolean'
Migration:
$table->boolean('field_name')->default(false);
Date
For dates without time.
'column_type' => 'date'
Migration:
$table->date('field_name')->nullable();
DateTime
For dates with time.
'column_type' => 'datetime'
Migration:
$table->datetime('field_name')->nullable();
JSON
For structured data.
'column_type' => 'json'
Migration:
$table->json('field_name')->nullable();
Migration Generation
Automatic Migration
When you create a custom field, a migration is automatically generated:
$customFieldManager = app(CustomFieldManager::class);
$field = $customFieldManager->createField([
'name' => 'Price',
'key' => 'price',
'type' => 'number',
'column_type' => 'decimal',
'content_types' => ['products'],
'required' => false,
]);
// Migration automatically created in database/migrations/
Rejected keys
Since 2.7.1, createField() throws InvalidArgumentException when the key is
unavailable, and CustomFieldRequest surfaces the same conflict as a
field-level validation error so the admin UI shows a message rather than a 500.
Both call CustomFieldManager::findKeyConflict(), so the API and the UI cannot
drift apart. A key is rejected when it:
- appears in
CustomFieldManager::RESERVED_FIELD_KEYS(id,author_id,status,published_at,slug,parent_id,metadata,password,created_at,updated_at,deleted_at,user_id,uuid,remember_token); or - already names a column on one of the target content types' tables.
The reason is that addColumnToTable() silently returns when the column already
exists. Without the check, creating a field named author_id did not fail — the
existing column was adopted as a custom field, and from then on any content
editor's custom_fields[author_id] wrote the real column through the
DB-persisted-field exemption. That is a permanent, quiet escalation from
"manage custom fields" to "reassign authorship of any post", and a footgun even
without a malicious actor.
Generated Migration Example
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->decimal('price', 10, 2)->nullable();
});
}
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropColumn('price');
});
}
};
Manual Migration Generation
Generate a migration manually:
$customFieldManager = app(CustomFieldManager::class);
$field = CustomField::where('key', 'price')->first();
$migrationPath = $customFieldManager->generateMigration($field, 'add');
// Returns: database/migrations/2025_01_13_120000_add_price_to_content_types.php
Running Migrations
# Run all pending migrations
php artisan migrate
# Rollback last migration
php artisan migrate:rollback
# Rollback all migrations
php artisan migrate:reset
Retrieving and Displaying Values
Direct Column Access
Access custom fields as regular model properties:
$product = Product::find(1);
// Get custom field value
$price = $product->price;
$rating = $product->rating;
$featured = $product->featured;
// Set custom field value
$product->price = 29.99;
$product->rating = 5;
$product->featured = true;
$product->save();
In Queries
Use custom fields in queries:
// Where clause
$products = Product::where('price', '>', 100)->get();
// Order by
$products = Product::orderBy('rating', 'desc')->get();
// Select specific fields
$products = Product::select('id', 'title', 'price', 'rating')->get();
// Multiple conditions
$products = Product::where('featured', true)
->where('price', '<', 50)
->orderBy('rating', 'desc')
->get();
In Blade Templates
<div class="product">
<h2>{{ $product->title }}</h2>
<p>Price: ${{ number_format($product->price, 2) }}</p>
<p>Rating: {{ $product->rating }}/5</p>
@if($product->featured)
<span class="badge">Featured</span>
@endif
</div>
With Eloquent Relationships
// Eager load with custom fields
$products = Product::with('categories')
->where('price', '<', 100)
->get();
// Filter relationships
$category = Category::find(1);
$products = $category->products()
->where('featured', true)
->orderBy('price', 'asc')
->get();
Applying Values From Untrusted Input
A custom-field payload that came from request input must be applied with
applyCustomFieldValues(), not by assigning each key directly:
// Correct — drops keys that shadow a real column.
$post->applyCustomFieldValues($request->input('custom_fields', []));
// Unsafe — `custom_fields[author_id]` would overwrite the real column.
foreach ($request->input('custom_fields', []) as $key => $value) {
$post->{$key} = $value;
}
The magic setter routes a metadata-storage field into the model's metadata
JSON column, but a key naming a real column falls through to Eloquent and
writes that column — and the custom-field half of a payload gets no fillable
filtering.
applyCustomFieldValues() is an allowlist. A key is applied only when it
names a custom field actually registered for the model's content type; every
other key is dropped and logged. In particular, all of these are dropped:
- an unknown key, whether or not it names a column;
- a key naming a real DB column, cast, mutator, accessor, or relation;
- a case variant of one of those —
AUTHOR_IDresolves toauthor_idon MySQL and SQLite, which treat identifiers case-insensitively; - a JSON-path key such as
metadata->xortitle->x; - one of Eloquent's own properties (
table,exists,attributes, …).
Values are assigned through setAttribute(), never through a dynamic property
write, so a payload can never reach the model's internal state.
DB-registered fields are the exception on the column half: they own the column
they name, so their values still write through. Filter-registered fields never
qualify for that exemption, even if the registration declares
'storage' => 'column' — only a row in the custom_fields table can authorize
a real-column write. Since 2.7.1 such a row can no longer be created with a key
that already names a column on the target table, nor with a framework-reserved
key (id, author_id, status, published_at, slug, parent_id,
metadata, password, …) — see CustomFieldManager::RESERVED_FIELD_KEYS.
Dropped keys emit a Log::warning carrying the key, the reason, and the model,
so a plugin author whose field "just doesn't save" has a breadcrumb, and an
operator has a signal that someone is probing custom_fields[author_id].
BlogManager::create()/update() and PageManager::create()/update() apply
their $customFields argument through this method already, so callers passing
custom fields to a manager are covered without doing anything.
Direct assignment from your own code is unaffected — $post->title = 'New'
still writes the column even when a plugin has registered title as a custom
field. Only the payload path is guarded, so a registration can never block a
host model from writing its own columns.
Data Migration
Changing Field Types
When changing a custom field's type, data migration may be needed:
Example: String to Integer
// Before: price stored as string "29.99"
// After: price should be decimal 29.99
// 1. Create new migration
Schema::table('products', function (Blueprint $table) {
$table->decimal('price_new', 10, 2)->nullable();
});
// 2. Migrate data
Product::chunk(100, function ($products) {
foreach ($products as $product) {
$product->price_new = floatval($product->price);
$product->save();
}
});
// 3. Drop old column, rename new
Schema::table('products', function (Blueprint $table) {
$table->dropColumn('price');
$table->renameColumn('price_new', 'price');
});
Handling Null Values
// Set default for existing records
Product::whereNull('rating')->update(['rating' => 0]);
// Or in migration
Schema::table('products', function (Blueprint $table) {
$table->integer('rating')->default(0)->change();
});
Converting JSON to Columns
// Before: metadata JSON {'color': 'red', 'size': 'large'}
// After: color and size columns
Schema::table('products', function (Blueprint $table) {
$table->string('color')->nullable();
$table->string('size')->nullable();
});
Product::chunk(100, function ($products) {
foreach ($products as $product) {
$metadata = $product->metadata;
$product->color = $metadata['color'] ?? null;
$product->size = $metadata['size'] ?? null;
$product->save();
}
});
Best Practices
1. Use Descriptive Keys
✓ 'key' => 'product_price'
✓ 'key' => 'event_start_date'
✗ 'key' => 'price1'
✗ 'key' => 'field_1'
2. Choose Appropriate Column Types
// For prices
'column_type' => 'decimal' // Not 'string' or 'float'
// For flags
'column_type' => 'boolean' // Not 'integer' or 'string'
// For dates
'column_type' => 'date' // Not 'string'
3. Set Sensible Defaults
'default_value' => 0, // For numbers
'default_value' => false, // For booleans
'default_value' => '', // For strings
4. Use Required Sparingly
'required' => false, // Most fields should be optional
5. Add Descriptions
'description' => 'Product price in USD. Enter without currency symbol.',
6. Order Fields Logically
'order' => 1, // Display order in forms
'order' => 2,
'order' => 3,
7. Index Heavy-Use Fields
// In migration
Schema::table('products', function (Blueprint $table) {
$table->decimal('price', 10, 2)->nullable()->index();
$table->boolean('featured')->default(false)->index();
});
Examples
Product Price
$customFieldManager->registerField([
'name' => 'Price',
'key' => 'price',
'type' => 'number',
'column_type' => 'decimal',
'description' => 'Product price in USD',
'content_types' => ['products'],
'options' => ['precision' => 10, 'scale' => 2],
'order' => 1,
'required' => true,
'default_value' => '0.00',
]);
Event Date
$customFieldManager->registerField([
'name' => 'Event Date',
'key' => 'event_date',
'type' => 'date',
'column_type' => 'datetime',
'description' => 'When the event starts',
'content_types' => ['events'],
'order' => 1,
'required' => true,
]);
Featured Flag
$customFieldManager->registerField([
'name' => 'Featured',
'key' => 'featured',
'type' => 'boolean',
'column_type' => 'boolean',
'description' => 'Show on homepage',
'content_types' => ['products', 'posts'],
'order' => 10,
'required' => false,
'default_value' => false,
]);
Product Rating
$customFieldManager->registerField([
'name' => 'Rating',
'key' => 'rating',
'type' => 'number',
'column_type' => 'integer',
'description' => 'Product rating (1-5)',
'content_types' => ['products'],
'options' => ['min' => 1, 'max' => 5],
'order' => 5,
'required' => false,
'default_value' => 3,
]);
Deleting Custom Fields
Via Manager
$customFieldManager = app(CustomFieldManager::class);
$customFieldManager->deleteField($fieldId);
// Automatically:
// 1. Removes column from all content type tables
// 2. Generates migration
// 3. Deletes field from database
Warning
Deleting a custom field permanently removes data. Always backup before deleting.
See Also
- Content Types Developer Guide
- Taxonomies Developer Guide
- Enums Reference - FieldType and ColumnType enums
- Hooks Reference

