In an Accounting SaaS App based on laravel, I need to implement a full featured custom field functionality to be added to different table.
The attached images is from smart office. So they have a list, and clicking the list brings up the custom field designer with some props. And this can be added to the the Create edit screen.
Suggest me the best way to achieve this. I'm using laravel 12, blade, bootstrap, jQuery. Suggest me both from the architechture point of view and also from the UI as well.
Here’s a pattern that works well for “custom fields anywhere in the app” in Laravel + Blade + Bootstrap + jQuery.
-
Different modules:
Customer,Invoice,Product, etc. -
Many field types: text, number, date, dropdown, checkbox, file, etc.
-
Per-record values.
-
Designer UI to create/update fields and order them.
-
Easy inclusion on create/edit forms.
This is essentially a small EAV system, but just for custom fields (your core columns stay normal).
1. custom_field_groups (optional but nice)
Groups fields by module / context.
Schema::create('custom_field_groups', function (Blueprint $table) {
$table->id();
$table->string('name'); // "Customers", "Invoices"
$table->string('model_type'); // e.g. App\Models\Customer
$table->string('slug')->unique();
$table->timestamps();
});2. custom_fields — defines the fields
Schema::create('custom_fields', function (Blueprint $table) {
$table->id();
$table->foreignId('group_id')->constrained('custom_field_groups');
$table->string('name'); // internal key: 'vat_number'
$table->string('label'); // "VAT Number"
$table->string('type'); // 'text','number','date','dropdown',...
$table->boolean('required')->default(false);
$table->unsignedInteger('position')->default(0);
$table->json('settings')->nullable();
// e.g. {"options":["Option 1","Option 2"],"min":0,"max":999}
$table->string('help_text')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});3. custom_field_values — stores actual values
Schema::create('custom_field_values', function (Blueprint $table) {
$table->id();
$table->foreignId('custom_field_id')->constrained('custom_fields');
// Link to *any* model using polymorphic relation
$table->morphs('valuable'); // valuable_type, valuable_id
$table->text('value')->nullable(); // store as string/JSON, cast later
$table->timestamps();
});Storing in a single value text column is easiest; you can cast to appropriate type in PHP when needed.
Trait for models that can have custom fields
// app/Models/Concerns/HasCustomFields.php
trait HasCustomFields
{
public function customFieldValues()
{
return $this->morphMany(CustomFieldValue::class, 'valuable');
}
public function customFields()
{
// via group->fields
return CustomField::whereHas('group', function ($q) {
$q->where('model_type', static::class);
})->orderBy('position');
}
public function getCustomFieldValue($fieldName, $default = null)
{
$field = CustomField::where('name', $fieldName)
->whereHas('group', fn ($q) => $q->where('model_type', static::class))
->first();
if (! $field) return $default;
$value = $this->customFieldValues
->firstWhere('custom_field_id', $field->id);
return $value?->casted_value ?? $default;
}
}CustomFieldValue can have an accessor casted_value that converts based on field type.
Service to handle saving/validation
class CustomFieldService
{
public function getFieldsForModel(string $modelClass)
{
return CustomField::whereHas('group', fn($q) => $q->where('model_type', $modelClass))
->where('is_active', true)
->orderBy('position')
->get();
}
public function validationRules(Collection $fields): array
{
$rules = [];
foreach ($fields as $field) {
$name = "custom_fields.{$field->id}";
$fieldRules = [];
if ($field->required) {
$fieldRules[] = 'required';
} else {
$fieldRules[] = 'nullable';
}
switch ($field->type) {
case 'number': $fieldRules[] = 'numeric'; break;
case 'date': $fieldRules[] = 'date'; break;
case 'email': $fieldRules[] = 'email'; break;
case 'dropdown': $fieldRules[] = 'in:' . implode(',', $field->settings['options'] ?? []); break;
// ...
}
$rules[$name] = $fieldRules;
}
return $rules;
}
public function saveValues(Model $model, array $data, Collection $fields)
{
foreach ($fields as $field) {
$key = (string) $field->id;
$value = $data[$key] ?? null;
$model->customFieldValues()
->updateOrCreate(
['custom_field_id' => $field->id],
['value' => $value]
);
}
}
}Then in a FormRequest you can merge the dynamic rules from the service.
Example for CustomerController@create:
public function create(CustomFieldService $customFieldService)
{
$fields = $customFieldService->getFieldsForModel(Customer::class);
return view('customers.create', compact('fields'));
}
public function store(CustomerRequest $request, CustomFieldService $customFieldService)
{
$customer = Customer::create($request->validatedRegularAttributes());
$fields = $customFieldService->getFieldsForModel(Customer::class);
$customFieldService->saveValues(
$customer,
$request->input('custom_fields', []),
$fields
);
// redirect...
}In your CustomerRequest, call $customFieldService->validationRules($fields) and merge into rules.
Create one partial and reuse it everywhere.
{{-- resources/views/custom_fields/_form.blade.php --}}
@foreach($fields as $field)
@php
$fieldName = "custom_fields[{$field->id}]";
$value = $values[$field->id] ?? null;
@endphp
<div class="mb-3" data-custom-field-id="{{ $field->id }}">
<label class="form-label">
{{ $field->label }}
@if($field->required) <span class="text-danger">*</span> @endif
</label>
@switch($field->type)
@case('text')
<input type="text" name="{{ $fieldName }}" value="{{ old($fieldName, $value) }}"
class="form-control">
@break
@case('number')
<input type="number" name="{{ $fieldName }}" value="{{ old($fieldName, $value) }}"
class="form-control">
@break
@case('date')
<input type="date" name="{{ $fieldName }}" value="{{ old($fieldName, $value) }}"
class="form-control">
@break
@case('dropdown')
<select name="{{ $fieldName }}" class="form-select">
<option value="">-- Select --</option>
@foreach($field->settings['options'] ?? [] as $option)
<option value="{{ $option }}" @selected(old($fieldName, $value) == $option)>
{{ $option }}
</option>
@endforeach
</select>
@break
{{-- add checkbox, radio, textarea, file, etc. --}}
@endswitch
@if($field->help_text)
<small class="form-text text-muted">{{ $field->help_text }}</small>
@endif
@error("custom_fields.{$field->id}")
<div class="text-danger small">{{ $message }}</div>
@enderror
</div>
@endforeach
Then in your create/edit views:
@include('custom_fields._form', [
'fields' => $fields,
'values' => $customer->customFieldValues->pluck('value','custom_field_id') ?? []
])
Use a Bootstrap offcanvas or side drawer to mimic SmartOffice.
-
Left: list of field types (“Date, Text, Phone, Email, …”) — static list in Blade.
-
Right: form for creating a new field or editing one.
Routes
Route::resource('custom-field-groups', CustomFieldGroupController::class);
Route::resource('custom-fields', CustomFieldController::class);Or have nested routes under groups.
-
User clicks “Add Field”.
-
Show offcanvas or modal with form:
-
Field Name (machine name)
-
Label
-
Description / Help Text
-
Type (select)
-
Position (dropdown or numeric)
-
Required (toggle switch)
-
Type-specific settings area.
-
Example Blade:
<div class="offcanvas offcanvas-end" tabindex="-1" id="fieldDesigner">
<div class="offcanvas-header">
<h5>Create Field</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas"></button>
</div>
<div class="offcanvas-body">
<form id="customFieldForm" method="POST" action="{{ route('custom-fields.store') }}">
@csrf
<input type="hidden" name="group_id" value="{{ $group->id }}">
<div class="mb-3">
<label class="form-label">Field Name</label>
<input name="name" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Label</label>
<input name="label" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Description (Optional)</label>
<input name="help_text" class="form-control">
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<select name="type" id="fieldType" class="form-select">
<option value="text">Text</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="dropdown">Dropdown</option>
<option value="checkbox">Checkbox</option>
<!-- etc -->
</select>
</div>
<div id="typeSettings"></div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" name="required" value="1" id="requiredSwitch">
<label class="form-check-label" for="requiredSwitch">Required in form</label>
</div>
<button type="submit" class="btn btn-primary w-100">Create</button>
</form>
</div>
</div>
For example, for dropdown options:
$(function () {
function renderSettings(type) {
var $settings = $('#typeSettings');
$settings.empty();
if (type === 'dropdown' || type === 'radio') {
$settings.append(`
<div class="mb-3">
<label class="form-label">Options (one per line)</label>
<textarea name="settings[options]" class="form-control" rows="4"></textarea>
</div>
`);
} else if (type === 'number') {
$settings.append(`
<div class="row">
<div class="col">
<label class="form-label">Min</label>
<input type="number" name="settings[min]" class="form-control">
</div>
<div class="col">
<label class="form-label">Max</label>
<input type="number" name="settings[max]" class="form-control">
</div>
</div>
`);
}
// add others as needed
}
$('#fieldType').on('change', function () {
renderSettings(this.value);
});
renderSettings($('#fieldType').val());
});In your controller, parse settings[options] into an array and store in the settings JSON column.
Use a simple list with data-id attributes and jQuery UI Sortable:
<ul id="fieldList" class="list-group">
@foreach($fields as $field)
<li class="list-group-item d-flex align-items-center" data-id="{{ $field->id }}">
<span class="me-2"><i class="bi bi-grip-vertical"></i></span>
{{ $field->label }}
</li>
@endforeach
</ul>
$('#fieldList').sortable({
update: function () {
var order = $(this).children().map(function (i, li) {
return $(li).data('id');
}).get();
$.post('{{ route('custom-fields.reorder') }}', {
_token: '{{ csrf_token() }}',
order: order
});
}
});On the backend update position based on that order.
-
Show preview in the designer: when user changes type/label/options, render a preview field at the bottom using the same Blade partial or a small jQuery template.
-
Validation feedback: reuse Laravel error messages so the custom fields behave exactly like normal ones.
-
Consistency: use Bootstrap’s standard
form-control,form-select,form-checkto keep everything visually aligned with the rest of your app. -
Permissions: only admins should access the designer; normal users just see the generated fields.
If you’d like, I can next sketch out concrete migrations + a minimal CustomFieldController so you can just drop them into your Laravel project and tweak.
Awesome, let’s turn the idea into something you can actually drop in.
I’ll give you:
-
Migrations
-
Models + trait
-
Service class
-
Minimal controllers (groups + fields)
-
Example: using it in a
CustomerController
You can then wire the Blade views using the partial pattern from my previous reply.
php artisan make:migration create_custom_field_groups_table
// database/migrations/xxxx_xx_xx_xxxxxx_create_custom_field_groups_table.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::create('custom_field_groups', function (Blueprint $table) {
$table->id();
$table->string('name'); // "Customers", "Invoices"
$table->string('slug')->unique();
$table->string('model_type'); // e.g. App\Models\Customer
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('custom_field_groups');
}
};php artisan make:migration create_custom_fields_table
// database/migrations/xxxx_xx_xx_xxxxxx_create_custom_fields_table.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::create('custom_fields', function (Blueprint $table) {
$table->id();
$table->foreignId('group_id')
->constrained('custom_field_groups')
->cascadeOnDelete();
$table->string('name'); // machine name: vat_number
$table->string('label'); // human label
$table->string('type'); // text, number, date, dropdown, etc
$table->boolean('required')->default(false);
$table->unsignedInteger('position')->default(0);
$table->json('settings')->nullable(); // type-specific config
$table->string('help_text')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->unique(['group_id', 'name']); // unique per group
});
}
public function down(): void
{
Schema::dropIfExists('custom_fields');
}
};php artisan make:migration create_custom_field_values_table
// database/migrations/xxxx_xx_xx_xxxxxx_create_custom_field_values_table.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::create('custom_field_values', function (Blueprint $table) {
$table->id();
$table->foreignId('custom_field_id')
->constrained('custom_fields')
->cascadeOnDelete();
$table->morphs('valuable'); // valuable_id, valuable_type
$table->text('value')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('custom_field_values');
}
};Then:
php artisan migrate
// app/Models/CustomFieldGroup.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class CustomFieldGroup extends Model
{
protected $fillable = [
'name',
'slug',
'model_type',
];
public function fields(): HasMany
{
return $this->hasMany(CustomField::class, 'group_id')->orderBy('position');
}
}// app/Models/CustomField.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class CustomField extends Model
{
protected $fillable = [
'group_id',
'name',
'label',
'type',
'required',
'position',
'settings',
'help_text',
'is_active',
];
protected $casts = [
'required' => 'bool',
'is_active' => 'bool',
'settings' => 'array',
];
public function group(): BelongsTo
{
return $this->belongsTo(CustomFieldGroup::class, 'group_id');
}
public function values(): HasMany
{
return $this->hasMany(CustomFieldValue::class);
}
}// app/Models/CustomFieldValue.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class CustomFieldValue extends Model
{
protected $fillable = [
'custom_field_id',
'valuable_id',
'valuable_type',
'value',
];
public function field(): BelongsTo
{
return $this->belongsTo(CustomField::class, 'custom_field_id');
}
public function valuable(): MorphTo
{
return $this->morphTo();
}
/**
* Cast value based on field type.
*/
public function getCastedValueAttribute()
{
$type = $this->field->type ?? 'text';
return match ($type) {
'number' => is_numeric($this->value) ? 0 + $this->value : null,
'date' => $this->value ? \Carbon\Carbon::parse($this->value) : null,
'checkbox' => (bool) $this->value,
default => $this->value,
};
}
}// app/Models/Concerns/HasCustomFields.php
namespace App\Models\Concerns;
use App\Models\CustomField;
use App\Models\CustomFieldValue;
use Illuminate\Database\Eloquent\Relations\MorphMany;
trait HasCustomFields
{
public function customFieldValues(): MorphMany
{
return $this->morphMany(CustomFieldValue::class, 'valuable');
}
public function customFields()
{
return CustomField::whereHas('group', function ($q) {
$q->where('model_type', static::class);
})->where('is_active', true)
->orderBy('position')
->get();
}
public function getCustomField(string $name, $default = null)
{
$field = CustomField::where('name', $name)
->whereHas('group', fn ($q) => $q->where('model_type', static::class))
->first();
if (! $field) return $default;
$value = $this->customFieldValues
->firstWhere('custom_field_id', $field->id);
return $value?->casted_value ?? $default;
}
}Use it in any model:
// app/Models/Customer.php
use App\Models\Concerns\HasCustomFields;
class Customer extends Model
{
use HasCustomFields;
// ...
}// app/Services/CustomFieldService.php
namespace App\Services;
use App\Models\CustomField;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
class CustomFieldService
{
public function getFieldsForModel(string $modelClass): Collection
{
return CustomField::whereHas('group', fn ($q) =>
$q->where('model_type', $modelClass)
)
->where('is_active', true)
->orderBy('position')
->get();
}
public function validationRules(Collection $fields): array
{
$rules = [];
foreach ($fields as $field) {
$name = "custom_fields.{$field->id}";
$fieldRules = $field->required ? ['required'] : ['nullable'];
switch ($field->type) {
case 'number':
$fieldRules[] = 'numeric';
break;
case 'date':
$fieldRules[] = 'date';
break;
case 'email':
$fieldRules[] = 'email';
break;
case 'dropdown':
case 'radio':
$options = $field->settings['options'] ?? [];
if (! empty($options)) {
$fieldRules[] = 'in:' . implode(',', $options);
}
break;
}
$rules[$name] = $fieldRules;
}
return $rules;
}
public function saveValues(Model $model, array $data, Collection $fields): void
{
foreach ($fields as $field) {
$value = $data[$field->id] ?? null;
$model->customFieldValues()
->updateOrCreate(
['custom_field_id' => $field->id],
['value' => $value]
);
}
}
}You can put them in App\Http\Controllers\Admin or wherever fits your structure.
// routes/web.php
use App\Http\Controllers\CustomFieldGroupController;
use App\Http\Controllers\CustomFieldController;
Route::prefix('admin')->middleware(['auth'])->group(function () {
Route::resource('custom-field-groups', CustomFieldGroupController::class);
Route::get('custom-field-groups/{group}/fields', [CustomFieldController::class, 'index'])
->name('custom-fields.index');
Route::post('custom-field-groups/{group}/fields', [CustomFieldController::class, 'store'])
->name('custom-fields.store');
Route::get('custom-fields/{field}/edit', [CustomFieldController::class, 'edit'])
->name('custom-fields.edit');
Route::put('custom-fields/{field}', [CustomFieldController::class, 'update'])
->name('custom-fields.update');
Route::delete('custom-fields/{field}', [CustomFieldController::class, 'destroy'])
->name('custom-fields.destroy');
Route::post('custom-fields/reorder', [CustomFieldController::class, 'reorder'])
->name('custom-fields.reorder');
});// app/Http/Controllers/CustomFieldGroupController.php
namespace App\Http\Controllers;
use App\Models\CustomFieldGroup;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class CustomFieldGroupController extends Controller
{
public function index()
{
$groups = CustomFieldGroup::orderBy('name')->get();
return view('admin.custom_fields.groups.index', compact('groups'));
}
public function create()
{
return view('admin.custom_fields.groups.create');
}
public function store(Request $request)
{
$data = $request->validate([
'name' => 'required|string|max:255',
'slug' => 'nullable|string|max:255|unique:custom_field_groups,slug',
'model_type' => 'required|string|max:255',
]);
if (empty($data['slug'])) {
$data['slug'] = Str::slug($data['name']);
}
CustomFieldGroup::create($data);
return redirect()->route('custom-field-groups.index')
->with('success', 'Group created.');
}
public function edit(CustomFieldGroup $customFieldGroup)
{
return view('admin.custom_fields.groups.edit', [
'group' => $customFieldGroup,
]);
}
public function update(Request $request, CustomFieldGroup $customFieldGroup)
{
$data = $request->validate([
'name' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:custom_field_groups,slug,' . $customFieldGroup->id,
'model_type' => 'required|string|max:255',
]);
$customFieldGroup->update($data);
return redirect()->route('custom-field-groups.index')
->with('success', 'Group updated.');
}
public function destroy(CustomFieldGroup $customFieldGroup)
{
$customFieldGroup->delete();
return back()->with('success', 'Group deleted.');
}
}// app/Http/Controllers/CustomFieldController.php
namespace App\Http\Controllers;
use App\Models\CustomField;
use App\Models\CustomFieldGroup;
use Illuminate\Http\Request;
class CustomFieldController extends Controller
{
public function index(CustomFieldGroup $group)
{
$fields = $group->fields; // ordered by position (from model)
// Render the designer UI (list + “Create Field” offcanvas)
return view('admin.custom_fields.fields.index', compact('group', 'fields'));
}
public function store(Request $request, CustomFieldGroup $group)
{
$data = $request->validate([
'name' => 'required|string|max:255',
'label' => 'required|string|max:255',
'type' => 'required|string|max:50',
'required' => 'nullable|boolean',
'help_text' => 'nullable|string|max:255',
'settings' => 'array',
]);
$data['required'] = $request->boolean('required');
$data['settings'] = $this->normalizeSettings($data['settings'] ?? []);
$data['position'] = $group->fields()->max('position') + 1;
$data['group_id'] = $group->id;
CustomField::create($data);
return back()->with('success', 'Field created.');
}
public function edit(CustomField $field)
{
$group = $field->group;
return view('admin.custom_fields.fields.edit', compact('field', 'group'));
}
public function update(Request $request, CustomField $field)
{
$data = $request->validate([
'label' => 'required|string|max:255',
'type' => 'required|string|max:50',
'required' => 'nullable|boolean',
'help_text' => 'nullable|string|max:255',
'settings' => 'array',
'is_active' => 'nullable|boolean',
]);
$data['required'] = $request->boolean('required');
$data['is_active'] = $request->boolean('is_active');
$data['settings'] = $this->normalizeSettings($data['settings'] ?? []);
$field->update($data);
return redirect()
->route('custom-fields.index', $field->group)
->with('success', 'Field updated.');
}
public function destroy(CustomField $field)
{
$field->delete();
return back()->with('success', 'Field deleted.');
}
public function reorder(Request $request)
{
$data = $request->validate([
'order' => 'required|array',
'order.*' => 'integer',
]);
foreach ($data['order'] as $position => $id) {
CustomField::where('id', $id)->update(['position' => $position]);
}
return response()->json(['status' => 'ok']);
}
protected function normalizeSettings(array $settings): array
{
// Example: convert "options" textarea into array if string passed
if (isset($settings['options']) && is_string($settings['options'])) {
$lines = preg_split('/\r\n|\r|\n/', $settings['options']);
$settings['options'] = array_values(array_filter(array_map('trim', $lines)));
}
return $settings;
}
}// app/Http/Controllers/CustomerController.php
namespace App\Http\Controllers;
use App\Models\Customer;
use App\Services\CustomFieldService;
use Illuminate\Http\Request;
class CustomerController extends Controller
{
public function create(CustomFieldService $customFieldService)
{
$fields = $customFieldService->getFieldsForModel(Customer::class);
return view('customers.create', compact('fields'));
}
public function store(Request $request, CustomFieldService $customFieldService)
{
$fields = $customFieldService->getFieldsForModel(Customer::class);
// normal attributes validation
$rules = [
'name' => 'required|string|max:255',
// ...
];
// merge dynamic rules
$rules = array_merge($rules, $customFieldService->validationRules($fields));
$validated = $request->validate($rules);
$customer = Customer::create([
'name' => $validated['name'],
// ...
]);
$customFieldService->saveValues(
$customer,
$request->input('custom_fields', []),
$fields
);
return redirect()->route('customers.index')
->with('success', 'Customer created.');
}
public function edit(Customer $customer, CustomFieldService $customFieldService)
{
$fields = $customFieldService->getFieldsForModel(Customer::class);
$values = $customer->customFieldValues->pluck('value', 'custom_field_id');
return view('customers.edit', compact('customer', 'fields', 'values'));
}
public function update(Request $request, Customer $customer, CustomFieldService $customFieldService)
{
$fields = $customFieldService->getFieldsForModel(Customer::class);
$rules = [
'name' => 'required|string|max:255',
];
$rules = array_merge($rules, $customFieldService->validationRules($fields));
$validated = $request->validate($rules);
$customer->update([
'name' => $validated['name'],
]);
$customFieldService->saveValues(
$customer,
$request->input('custom_fields', []),
$fields
);
return redirect()->route('customers.index')
->with('success', 'Customer updated.');
}
}On the Blade side you just use the partial we discussed earlier:
@include('custom_fields._form', [
'fields' => $fields,
'values' => $values ?? [],
])
If you want, next I can help you sketch the actual Bootstrap/jQuery designer view (list + offcanvas) to match the screenshot as closely as possible.
Nice, let’s make it look/behave like the screenshots.
I’ll give you one main view + an included offcanvas partial + jQuery.
resources/views/admin/custom_fields/fields/index.blade.php
@extends('layouts.app')
@section('content')
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0">{{ $group->name }} – Custom Fields</h4>
<small class="text-muted">{{ $group->model_type }}</small>
</div>
<button class="btn btn-primary"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#customFieldOffcanvas">
+ Add Custom Field
</button>
</div>
{{-- Existing fields list (sortable) --}}
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Fields</span>
<small class="text-muted">Drag to reorder</small>
</div>
<ul id="fieldList" class="list-group list-group-flush">
@forelse($fields as $field)
<li class="list-group-item d-flex align-items-center" data-id="{{ $field->id }}">
<span class="me-2 text-muted" style="cursor:grab;">
<i class="bi bi-grip-vertical"></i>
</span>
<div class="flex-grow-1">
<div class="fw-semibold">{{ $field->label }}</div>
<div class="small text-muted">
{{ $field->type }} · {{ $field->name }}
@if(!$field->is_active) · <span class="badge bg-secondary">disabled</span>@endif
</div>
</div>
<a href="{{ route('custom-fields.edit', $field) }}"
class="btn btn-sm btn-outline-secondary me-1">Edit</a>
<form action="{{ route('custom-fields.destroy', $field) }}"
method="POST" onsubmit="return confirm('Delete this field?')">
@csrf
@method('DELETE')
<button class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</li>
@empty
<li class="list-group-item text-muted">No custom fields yet.</li>
@endforelse
</ul>
</div>
</div>
@include('admin.custom_fields.fields._offcanvas')
@endsection
@push('scripts')
<script src="https://code.jquery.com/ui/1.13.2/jquery-ui.min.js"></script>
<script>
// sortable list
$('#fieldList').sortable({
handle: 'span',
update: function () {
var order = $(this).children().map(function () {
return $(this).data('id');
}).get();
$.post('{{ route('custom-fields.reorder') }}', {
_token: '{{ csrf_token() }}',
order: order
});
}
});
</script>
@endpush
resources/views/admin/custom_fields/fields/_offcanvas.blade.php
<div class="offcanvas offcanvas-end" tabindex="-1" id="customFieldOffcanvas">
<div class="offcanvas-header border-bottom">
<h5 class="offcanvas-title" id="offcanvasTitle">Add Field</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas"></button>
</div>
<div class="offcanvas-body p-0 d-flex flex-column" style="height:100%;">
{{-- STEP 1: type chooser --}}
<div id="screen-choose-type" class="flex-grow-1 d-flex flex-column">
<div class="p-3 border-bottom">
<div class="input-group">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" id="fieldTypeSearch" class="form-control" placeholder="Search">
</div>
</div>
<div class="px-3 pt-2 small text-uppercase text-muted fw-semibold">Popular</div>
<div class="list-group list-group-flush" id="fieldTypeList">
{{-- Popular --}}
<button type="button" class="list-group-item list-group-item-action type-item"
data-type="date" data-label="Date">
<i class="bi bi-calendar me-2"></i> Date
</button>
<button type="button" class="list-group-item list-group-item-action type-item"
data-type="text" data-label="Text">
<i class="bi bi-fonts me-2"></i> Text
</button>
<button type="button" class="list-group-item list-group-item-action type-item"
data-type="phone" data-label="Phone">
<i class="bi bi-telephone me-2"></i> Phone
</button>
<button type="button" class="list-group-item list-group-item-action type-item"
data-type="email" data-label="Email">
<i class="bi bi-envelope me-2"></i> Email
</button>
<button type="button" class="list-group-item list-group-item-action type-item"
data-type="number" data-label="Number">
<i class="bi bi-hash me-2"></i> Number
</button>
<button type="button" class="list-group-item list-group-item-action type-item"
data-type="file" data-label="File & Media">
<i class="bi bi-folder2-open me-2"></i> File & Media
</button>
<button type="button" class="list-group-item list-group-item-action type-item"
data-type="url" data-label="URL">
<i class="bi bi-link-45deg me-2"></i> URL
</button>
<button type="button" class="list-group-item list-group-item-action type-item"
data-type="labels" data-label="Labels">
<i class="bi bi-shield-check me-2"></i> Labels
</button>
{{-- ALL --}}
<div class="px-3 pt-3 pb-1 small text-uppercase text-muted fw-semibold">All</div>
<button type="button" class="list-group-item list-group-item-action type-item"
data-type="checkbox" data-label="Checkbox">
<i class="bi bi-check-square me-2"></i> Checkbox
</button>
<button type="button" class="list-group-item list-group-item-action type-item"
data-type="dropdown" data-label="Dropdown">
<i class="bi bi-caret-down-square me-2"></i> Dropdown
</button>
<button type="button" class="list-group-item list-group-item-action type-item"
data-type="radio" data-label="Radio Button">
<i class="bi bi-record-circle me-2"></i> Radio Button
</button>
<button type="button" class="list-group-item list-group-item-action type-item"
data-type="textarea" data-label="Text Area">
<i class="bi bi-textarea-t me-2"></i> Text Area
</button>
<button type="button" class="list-group-item list-group-item-action type-item"
data-type="password" data-label="Password">
<i class="bi bi-eye-slash me-2"></i> Password
</button>
<button type="button" class="list-group-item list-group-item-action type-item"
data-type="color" data-label="Color">
<i class="bi bi-palette me-2"></i> Color
</button>
{{-- Pre-defined --}}
<div class="px-3 pt-3 pb-1 small text-uppercase text-muted fw-semibold">Pre defined inputs</div>
<button type="button" class="list-group-item list-group-item-action type-item"
data-type="employee" data-label="All Employees">
<i class="bi bi-people me-2"></i> All Employees
</button>
</div>
<div class="p-3 border-top mt-auto">
<button class="btn btn-outline-secondary w-100" type="button">
Add Existing
</button>
</div>
</div>
{{-- STEP 2: create/configure field --}}
<div id="screen-create-field" class="flex-grow-1 d-none">
<form id="customFieldForm" method="POST"
action="{{ route('custom-fields.store', $group) }}"
class="h-100 d-flex flex-column">
@csrf
<div class="p-3 border-bottom d-flex align-items-center">
<button class="btn btn-link p-0 me-2" type="button" id="btnBackToTypes">
<i class="bi bi-chevron-left"></i>
</button>
<div>
<div class="fw-semibold">Create Field</div>
<small id="selectedTypeLabel" class="text-muted"></small>
</div>
</div>
<div class="p-3 flex-grow-1 overflow-auto">
<input type="hidden" name="type" id="fieldTypeInput">
<div class="mb-3">
<label class="form-label">Field Name</label>
<input type="text" name="name" class="form-control" required>
<div class="form-text">Used internally (no spaces).</div>
</div>
<div class="mb-3">
<label class="form-label">Label</label>
<input type="text" name="label" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Description (Optional)</label>
<input type="text" name="help_text" class="form-control">
<div class="form-text">
View descriptions when hovering over fields in tasks or views.
</div>
</div>
<div class="mb-3">
<label class="form-label">Position</label>
<select name="position" class="form-select">
@for ($i = 1; $i <= max(1, $fields->count()+1); $i++)
<option value="{{ $i }}" @selected($i === $fields->count()+1)>
{{ $i }}
</option>
@endfor
</select>
</div>
{{-- type-specific settings area --}}
<div id="typeSettings" class="mb-3"></div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="required" value="1" id="requiredSwitch">
<label class="form-check-label" for="requiredSwitch">Required in form</label>
</div>
</div>
<div class="p-3 border-top">
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary flex-grow-1"
data-bs-dismiss="offcanvas">
Cancel
</button>
<button type="submit" class="btn btn-primary flex-grow-1">
Create
</button>
</div>
</div>
</form>
</div>
</div>
</div>
@push('scripts')
<script>
// search filter in type list
$('#fieldTypeSearch').on('keyup', function () {
let q = $(this).val().toLowerCase();
$('#fieldTypeList .type-item').each(function () {
let text = $(this).text().toLowerCase();
$(this).toggle(text.indexOf(q) !== -1);
});
});
// click on type -> go to create screen
$('.type-item').on('click', function () {
let type = $(this).data('type');
let label = $(this).data('label');
$('#fieldTypeInput').val(type);
$('#selectedTypeLabel').text(label + ' field');
renderTypeSettings(type);
$('#screen-choose-type').addClass('d-none');
$('#screen-create-field').removeClass('d-none');
});
// back to type list
$('#btnBackToTypes').on('click', function () {
$('#screen-create-field').addClass('d-none');
$('#screen-choose-type').removeClass('d-none');
});
// render extra settings depending on field type
function renderTypeSettings(type) {
let $settings = $('#typeSettings');
$settings.empty();
if (type === 'dropdown' || type === 'radio' || type === 'labels') {
$settings.append(`
<label class="form-label">Options</label>
<textarea name="settings[options]" class="form-control" rows="4"
placeholder="One option per line"></textarea>
`);
} else if (type === 'number') {
$settings.append(`
<div class="row">
<div class="col">
<label class="form-label">Min</label>
<input type="number" name="settings[min]" class="form-control">
</div>
<div class="col">
<label class="form-label">Max</label>
<input type="number" name="settings[max]" class="form-control">
</div>
</div>
`);
} else if (type === 'color') {
$settings.append(`
<label class="form-label d-block">Default Color (optional)</label>
<input type="color" name="settings[default]" class="form-control form-control-color">
`);
} else if (type === 'date') {
$settings.append(`
<label class="form-label">Default</label>
<select name="settings[default]" class="form-select">
<option value="">None</option>
<option value="today">Today</option>
<option value="tomorrow">Tomorrow</option>
</select>
`);
}
// others can be added as needed
}
</script>
@endpush
Note: this uses Bootstrap Icons (
bi ...). If you’re not already including them, add their CDN<link>in your main layout<head>.
With these pieces you’ll have:
-
A designer page listing existing fields (sortable).
-
An offcanvas drawer that:
-
First shows the searchable list of field types (like your screenshot).
-
On click, flips to a Create Field screen with name/description/type/position/required + type-specific options.
-
-
The controller we wrote earlier will receive the form and create the field, normalizing
settings.