Filament: Table Tabs - Dynamically from Database

Filament Tables allow you to implement tabs easily. But did you know you can specify the tab names/values dynamically from the database, also controlling the order in which they appear? Let's take a look.

Customer Tabs

In this example, we have the following database table schema and Models.

database/migrations/XXXXXX_create_tiers_table.php

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

database/migrations/XXXXXX_create_customers_table.php

use App\Models\Tier;
 
// ...
 
Schema::create('customers', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Tier::class)->nullable()->constrained();
$table->string('full_name');
$table->timestamps();
});

app/Models/Tier.php

class Tier extends Model
{
// ...
 
protected $guarded = [];
 
public function customers(): HasMany
{
return $this->hasMany(Customer::class);
}
}

app/Models/Customer.php

class Customer extends Model
{
// ...
 
protected $guarded = [];
 
public function tier(): BelongsTo
{
return $this->belongsTo(Tier::class);
}
}

To add tabs, we must define the getTabs() method to your List<Resource>.php file. In this case, it is ListCustomers.

app/Filament/Resources/CustomerResource/Pages/ListCustomers.php

namespace App\Filament\Resources\CustomerResource\Pages;
 
use App\Filament\Resources\CustomerResource;
use App\Models\Tier;
use Filament\Actions;
use Filament\Resources\Components\Tab;
use Filament\Resources\Pages\ListRecords;
 
class ListCustomers extends ListRecords
{
protected static string $resource = CustomerResource::class;
 
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
 
 
public function getTabs(): array
{
$tabs = ['all' => Tab::make('All')->badge($this->getModel()::count())];
 
$tiers = Tier::orderBy('order_column', 'asc')
->withCount('customers')
->get();
 
foreach ($tiers as $tier) {
$name = $tier->name;
$slug = str($name)->slug()->toString();
 
$tabs[$slug] = Tab::make($name)
->badge($tier->customers_count)
->modifyQueryUsing(function ($query) use ($tier) {
return $query->where('tier_id', $tier->id);
});
}
 
return $tabs;
}
}

When returning the tabs array, the index of that array is a value of the activeTab URL parameter. So, we can generate a slug from the Tier name instead of just having a number.

//... /admin/customers?activeTab=platinum
$slug = str($name)->slug()->toString();

Tabs can have badges. The badge() method also accepts closures to display data depending on your custom logic. In our case, we show the Customer count in that Tier.

->badge($tier->customers_count)

Eloquent Query is modified with the ->modifyQueryUsing() method to filter records. Here, we extend the query to select Customers with the tier_id of the activeTab.

->modifyQueryUsing(function ($query) use ($tier) {
return $query->where('tier_id', $tier->id);
});

Tier tabs are sorted by the order_column column in ascending order.

$tiers = Tier::orderBy('order_column', 'asc')

You can change the order of tabs by modifying the order_column value on the EditTier page. Optionally, you can reorder rows directly on the ListTiers page by adding these methods to the TierResource@table method.

->defaultSort('order_column')
->reorderable('order_column')

Reorder Tiers

Reordered Tabs

Implemented Tier and Customer Resources are as follows.

app/Filament/Resources/TierResource.php

use Filament\Forms\Components\TextInput;
use Filament\Tables\Columns\TextColumn;
 
class TierResource extends Resource
{
// ...
 
public static function form(Form $form): Form
{
return $form
->schema([
TextInput::make('name')
->required(),
TextInput::make('order_column')
->required()
->numeric(),
]);
}
 
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('order_column'),
TextColumn::make('name'),
TextColumn::make('customers_count')
->counts('customers')
->badge(),
])
->defaultSort('order_column')
->reorderable('order_column')
// ...
}
}

app/Filament/Resources/CustomerResource.php

use App\Models\Tier;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Tables\Columns\TextColumn;
 
class CustomerResource extends Resource
{
// ...
 
public static function form(Form $form): Form
{
return $form
->schema([
TextInput::make('full_name')
->required(),
Select::make('tier_id')
->label('Tier')
->options(Tier::pluck('name', 'id')),
]);
}
 
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('full_name'),
TextColumn::make('tier.name')
->badge(),
])
// ...
}
}

Congratulations! You've successfully implemented dynamic Tabs for your Filament Table.


If you want more Filament examples, you can find more real-life projects on our FilamentExamples.com.

avatar

This is a great tutorial including Tabs. Is anyone aware of a solution where each tab can display only the needed table columns? My example would be to have a tab for income, another of expense, and a third for a complete list of both. I don't necessary need some of the colums listed in all three options. I apologize if the solution has already been addressed but I havent come accross it yet. Thank you and please keep up the good work!

avatar

Tabs are accessible as URL parameter, so you can add ->hidden() with a condition for all the tabs

avatar

Thank you for point me in the right direction.

avatar

Hello! Thank you for the great tutorial! It works perfectly if I put the getTabs in the ListCustomers. But If I put the same tabs and the same table in the custom page they work but the tabs don't affect the query of the table. Can you please point me towards the right direction?

avatar

got it. I was lurking through the ListRecords and found ->modifyQueryUsing($this->modifyQueryWithActiveTab(...)) applied it to the $table and voila

avatar

Hi ! Could you show me the demo code?

My approach


class ListMaterials

public function getTabs(): array
	{
		$restuls = [
			null => Tab::make(__('All')),
		];

		$items = CustomField::queryAllTypeFor('material', 'ASC');

		foreach ($items as $entry) {
			$restuls[$entry->getSlug()] = Tab::make()
				->query(
					fn ($query) => $query->whereHas(
						$entry->getSlug(),
						fn ($query) => $query->where('typeable', $entry->getSlug())
					)
				)->extraAttributes([
					'slug'=> $entry->getSlug(),
				])
				->label($entry->getName());
		}

		return $restuls;
	}
	
	public function table(Table $table): Table
	{
		$resource = static::getResource();

		$resource::customFieldsSlugConfig($this->activeTab);

		return $resource::table($table);
	}

class MaterialResource extends Resource
{
	....
	protected static ?string $customFieldsSlug = null;
	
	public static function table(Table $table): Table
	{
		return $table->columns([
		
			BadgeableColumn::make('name'),
			...static::previewTabForCustomExpand()
		
		])
	}
	
	public static function customFieldsSlugConfig(?string $customFieldsSlug)
	{
		static::$customFieldsSlug = $customFieldsSlug;
	}

	public static function previewTabForCustomExpand(): array
	{
		if (! static::$customFieldsSlug) {
			return [];
		}

		$custom = CustomField::queryTypeFor('material', static::$customFieldsSlug);

		$items = $custom?->fieldItems->getColumnComponents($custom->getSlug());

		return $items ?: [];
	}
}

It works fine, but I need to click the button twice to get it to work. It seems to be cached. But I have found a good solution.

avatar

Usually you have multiple filters on the table so the number in the badge should be updated accordingly. Although it seems trivial it is not.

You can use a helper function to remove the badge's where statement from the table query to show the correct numbers (reflecting the set filters of the table):

public function getTabs(): array
    {
        $tabs = [
            'all' => Tab::make('All')
                ->badge(fn() => $this->filterTypesFromQuery(
                        $this->getFilteredTableQuery()->clone()
                    )->count()
                )
        ];
				....
				$tabs[$slug] = Tab::make($name)
                ->badge(fn() => 
                    $this->filterTypesFromQuery(
											$this->getFilteredTableQuery()->clone()
                    )
                    ->where('type', $costType->value)
                    ->count()
                )
                ->modifyQueryUsing(function ($query) use ($costType) {
                    return $query->where('type', $costType->value);
                });
				...

the helper function look something like this ...

protected function filterTypesFromQuery($filteredTableQuery)
    {
        $wheres = array_filter(
            $filteredTableQuery->getQuery()->wheres, 
            function($where, $key) use ($filteredTableQuery) {
                if (isset($where['column'])) {
                    if ($where['column'] === 'type') {
                        unset($filteredTableQuery->getQuery()->bindings['where'][$key]);
                        return false;
                    }
                    return true;
                }
                return true;
            }, ARRAY_FILTER_USE_BOTH);

        $filteredTableQuery->getQuery()->wheres = $wheres;

        return $filteredTableQuery;
    }

Like our articles?

Become a Premium Member for $129/year or $29/month
What else you will get:
  • 68 courses (1188 lessons, total 43 h 18 min)
  • 90 long-form tutorials (one new every week)
  • access to project repositories
  • access to private Discord

Recent New Courses