Skip to main content

Custom Fields for Customers

Premium
7 min read

Quite often, CRMs do not fit in the pre-defined fields. This is where Custom Fields come in handy, as they allow users to set up their own fields for the CRM and fill Customer profiles with them:

In this lesson, we will do the following:

  • Create a Custom Field database table and Model
  • Create a pivot table for the Custom Field and Customer relationship
  • Create a pivot Model type for Filament to better handle the relationship
  • Create simple Custom Field seeders
  • Create a Custom Field CRUD (Filament Resource)
  • Add Custom Field to the Customer Resource via Repeater Component
  • Display Custom Fields on the Customer View page - we will generate them dynamically

Preparing Database, Models and Seeders

Let's start by creating our Custom Fields database. It will have just one field - name:

Migration

Schema::create('custom_fields', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});

Next, we know that this is going to be a many-to-many relationship, so we need a pivot table:

Migration

use App\Models\Customer;
use App\Models\CustomField;
 
// ...
 
Schema::create('custom_field_customer', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Customer::class)->constrained();
$table->foreignIdFor(CustomField::class)->constrained();
$table->string('value');
$table->timestamps();
});

Then, we can create our Models:

app/Models/CustomField.php

class CustomField extends Model
{
protected $fillable = [
'name'
];
}

And a pivot Model (Filament uses it to better handle the relationship):

app/Models/CustomFieldCustomer.php

use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Pivot;
 
class CustomFieldCustomer extends Pivot
{
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
 
public function customField(): BelongsTo
{
return $this->belongsTo(CustomField::class);
}
}

The last Model to update is the Customer Model, as we need to define the relationship:

Note: This is not a many-to-many relationship, as we use a pivot Model. So we need to use HasMany instead of BelongsToMany. It works the same as many-to-many, but now with an intermediate model!

app/Models/Customer.php

public function customFields(): HasMany
{
return $this->hasMany(CustomFieldCustomer::class);
}

Now, we can create our seeders:

database/seeders/DatabaseSeeder.php

use App\Models\CustomField;
 
// ...
 
public function run(): void
{
// ...
 
$customFields = [
'Birth Date',
'Company',
'Job Title',
'Family Members',
];
 
foreach ($customFields as $customField) {
CustomField::create(['name' => $customField]);
}
}

Running migrations and seeds:

php artisan migrate:fresh --seed

Should now give us a few Custom Fields in the database:


Creating Custom Field CRUD

We created the Resource CRUD with this command:

php artisan make:filament-resource CustomField --generate

Then, all we had to do - was move...

The Full Lesson is Only for Premium Members

Want to access all of our courses? (30 h 01 min)

You also get:

55 courses
Premium tutorials
Access to repositories
Private Discord
Get Premium for $129/year or $29/month

Already a member? Login here

Comments & Discussion

MR
Martin Radu ✓ Link copied!

in the CustomFieldCustomer you need to add the namespace also namespace App\Models;

M
Modestas ✓ Link copied!

Sorry, not sure I understand what the issue is here.

DB
Daniel Berry ✓ Link copied!

Great series, thank you for sharing.

RA
Richard A. Hoyle ✓ Link copied!

Using the php artisan make:model CustomFieldCustomer -m I usually get the following: Schema::create(‘custom_filed_customers’, function (Blueprint $table) However yours has: Schema::create(‘custom_filed_customer’, function (Blueprint $table)

So my issue is that the code you have is looking for the database without a S and the Make:model adds the S.

So Do I have to change my migration or can we change something some ware else to fix the problem?

M
Modestas ✓ Link copied!

To be fully honest with you - I never use artisan commands to create pivot tables. But in case you did, yeah - you need to change the migration to match what Laravel guesses.

It's a bit confusing, but pivot tables are "special" in some ways. One of them - they don't follow typical model naming conventions as they usually don't have a model associated with them.

WC
WILLY CHANDRA NEGARA ✓ Link copied!

You can use protected $table = 'YOURCUSTOMTABLE';

S
Sisifo ✓ Link copied!

First, really congrats for your course. I am enjoying a lot. You have been really doing a great course of filament, and at the same time "light"; so you don't enter in artisan details and things like this. However I think that this part of code needs a deeper explanation:

->disableOptionWhen(function ($value, $state, Get $get) {
return collect($get('../*.custom_field_id'))
->reject(fn($id) => $id === $state)
->filter()
->contains($value);

I am looking in filament documentation and I am getting to understand, however I think that can be part of the course.

M
Modestas ✓ Link copied!

Could you expand on what exactly needs more explanation here? This is mainly a Laravel thing, so we did not think to include this with filament

S
Sisifo ✓ Link copied!

Hi Modestas,

I have almost complete the course, programming the CMS "in paralelel"... and I have not seen anyother sites the structure "$get('../*.custom_field_id')" , and neither the method "reject()" or the "contrains()"

Thanks

M
Modestas ✓ Link copied!

The $get() thing comes from the data_get() method found in Laravel. This allows you to walk an array like it would be a file path (easiest explanation, but not quite identical). In this example, we went one level higher and looking at * index (this will take current field index) to find a field custom_field_id and get it's value.

In other words - this looks at all array for custom_field_id and collects them into a collection.

From there, we are using reject() to remove (filter) entries. You simply check each entry within a closure and reject them from a list if they match.

And lastly contains() is checking if the value is still available.


Hope that helps, but I am now aware on how complicated that might be at the start, sorry! This seemed like a typical workflow that you can expect with collections and their magic :)

S
Sisifo ✓ Link copied!

It helps really! Nothing to sorry about, to clarify details in the comments is the way.

I think this explanation can be in the content course. Any way, I hope here in the comments can be found as well.

S
Sisifo ✓ Link copied!

I am really surprised of the elegance of the "Pivot-inter-Model" philosophy. Thanks for that tip. I have not seen it before.

However, I would like to improve it a bit, because for example I don't like that everything have to be a "string", clearly there is a better mysql column type for "date of birth".

If we would like to have different types of "custom fields" (strings, dates, booleans, integers...), I am thinking in to have several "Type"_custom_field_customer for every type:

  • string_custom_fields with "$table->string('value');" (as in the course).
  • date_custom_fields with "$table->date('value');"
  • boolean_custom_fields with "$table->boolean('value');"
  • selection_custom_fields with "$table->foreignIdFor(CustomFieldSelection::class..." (here we would need another pivot table...)

However, I have not clear what to do in the models "CustomFieldCustomer.php" and "Customer.php" because I need to unificate another time the return...

I know that this matter is out of the scope of the course... however if you have any comment or help I would really appreciate. I hope it can help others as well.

M
Modestas ✓ Link copied!

While you are correct with the fact that having everything as string is pretty bad - the solutions to the problem are so complex that... It does not make much sense to make it. Here's what you would encounter:

  1. As mentioned in your post - now you have X fields to be filled. Who decides which field you will fill? This will become an overhead as you will have to "guess" which type of data this field is.
  2. Return can be done with Model attributes (and appending that attribute automatically to all model instances) - but even then - you will run A LOT of check operations.
  3. Models is the least of your concerns... This adds database search issues too as now you have 3+ fields to look at. This becomes complicated in my eyes and does not solve the initial problem.

That said, you would essentially move the load from database (which is pretty fast with indexes!) to PHP side, which is much slower at dealing with this. And while it solves one problem, it will eventually create another.

ps. I'm not saying it is not possible, but in my experience using that system - it always caused more issues and we just reverted back to single column :)

MF
Marc-Antoine Favreau ✓ Link copied!

I'm having issues writing tests for this kind of Pivot Model. Here's a test I have written for the tags, which also have a many-to-many relationship but I'm not using the Pivot Model, I'm simply using a BelongsToMany in the controllers. For the life of me, I can't seem to adapt the code for this kind of model. The attach() isn't available in the hasMany relationship, I kind of understand why, but I just can't figure out how to write a working. I know how to write a totally different test, using TableActions tests using Filament, but figuring this out would greatly help my understanding of the underlying code...

If anyone have a clue, it would greatly help.

Thanks!

it('can render the tag next the the customer name', function () {
auth()->user()->assignRole('Team Admin');
$tag = Tag::factory()
->for(auth()->user()->tenants()->first())
->create();
 
$customer = \App\Models\Customer::factory()
->for(auth()->user()->tenants()->first())
->for(\App\Models\Company::factory()->for(auth()->user()->tenants()->first()))
->create();
$customer->tags()->attach($tag);
 
livewire(\App\Filament\Resources\CustomerResource\Pages\ListCustomers::class)
->assertSeeInOrder([$customer->name, $tag->name]);
});
MF
Marc-Antoine Favreau ✓ Link copied!

Ok I,ve figured it out, here's my code if anyone is interested :

it('can render the Custom fields in the View Page of the customer', function () {
auth()->user()->assignRole('Team Admin');
$customfield = CustomField::factory()
->for($this->tenant)
->create();
 
$customer = \App\Models\Customer::factory()
->for($this->tenant)
->for(\App\Models\Company::factory()->for($this->tenant))
->create(['pipeline_stage_id' => \App\Models\PipelineStage::create([
'name' => 'Lead',
'position' => 1,
'is_default' => true,
])]);
$customer->customFieldCustomers()->create([
'custom_field_id' => $customfield->id,
'value' => 'test',
]);
 
livewire(\App\Filament\Resources\CustomerResource\Pages\ViewCustomer::class, [
'record' => $customer->getRouteKey(),
])
->assertOk()
->assertSeeInOrder([$customer->name, $customfield->name]);
});

We'd Love Your Feedback

Tell us what you like or what we can improve

Feel free to share anything you like or dislike about this page or the platform in general.