itul/livewire-slick-filters

A Laravel Livewire package for filtering, sorting, and paginating table data with live updates

Installs: 0

Dependents: 0

Suggesters: 0

Security: 0

pkg:composer/itul/livewire-slick-filters

v1.1.0 2025-10-21 13:07 UTC

This package is auto-updated.

Last update: 2025-10-21 13:09:07 UTC


README

A Laravel Livewire package for filtering, sorting, and paginating table data with live updates. Create DataTables-like functionality with ease using Livewire 3.

Features

  • 🚀 Zero-code deployment - Use <livewire:slick-table model="App\Models\User" /> directly in Blade - no PHP required!

  • 🧠 Intelligent automation - Auto-generates columns, labels, and filters from your model's $casts, $fillable, or database schema

  • 🎬 Built-in action dropdowns - Row actions with conditional visibility, confirmations, custom icons, and Livewire method integration

  • 🎛️ Advanced filter system - 6 filter types (Text, Numeric, Date, Date-Range, Numeric-Range, Select) with 15+ operators including NOT/negation support

  • 🎨 Rich customization - HTML display callbacks, conditional row/cell styling, active filter badges, relationship support, and lifecycle hooks

  • 🔗 Production-ready features - Shareable URLs, global search, sorting, pagination, multiple tables per page, and fully customizable views

➡️ See full feature list

Table of Contents

Installation

Requirements

  • PHP 8.1 or higher
  • Laravel 10.x, 11.x, or 12.x
  • Livewire 3.x

Install via Composer

composer require itul/livewire-slick-filters

Publish Configuration (Optional)

php artisan vendor:publish --tag=slick-filters-config

Publish Views (Optional)

php artisan vendor:publish --tag=slick-filters-views

Quick Start

Simplest: Generic Component (Blade Only)

The absolute easiest way - use the component directly from Blade without creating any PHP class:

<livewire:slick-table model="App\Models\User" />

That's literally it! Just pass your model class name. The component will:

  • ✅ Automatically create the query from your model
  • ✅ Auto-generate columns from $fillable (or database schema if no $fillable)
  • ✅ Auto-generate labels (e.g., email_verified_at → "Email Verified At")
  • ✅ Infer filter types from database schema
  • ✅ Make all columns sortable
  • ✅ Include all columns in global search

With options:

{{-- With custom per-page and sorting --}}
<livewire:slick-table
    model="App\Models\Product"
    sort-field="created_at"
    sort-direction="desc"
    :per-page="25"
/>

Perfect for:

  • Quick admin panels
  • Simple CRUD interfaces
  • Prototyping
  • Tables that don't need custom logic

Multiple tables on one page:

<div class="row">
    <div class="col-md-6">
        <livewire:slick-table model="App\Models\User" key="users" />
    </div>
    <div class="col-md-6">
        <livewire:slick-table model="App\Models\Order" key="orders" />
    </div>
</div>

Custom Component (Zero Config)

When you need customization (custom columns, filters, actions, etc.), create a custom component:

<?php

namespace App\Livewire;

use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Itul\LivewireSlickFilters\SlickTable;

class UsersTable extends SlickTable
{
    protected function query(): Builder
    {
        return User::query();
    }
}

That's it! The package will:

  • ✅ Auto-generate columns from User::$fillable (or database schema if no $fillable)
  • ✅ Auto-generate labels (e.g., email_verified_at → "Email Verified At")
  • ✅ Infer filter types from database schema (int → numeric, timestamp → date, enum → select)
  • ✅ Make all columns sortable
  • ✅ Include all columns in global search
  • ✅ Exclude sensitive fields (password, remember_token, and any fields in model's $hidden property)

Use it in your view:

<div>
    <livewire:users-table />
</div>

Custom Component (Defined Columns)

When you need full control over which columns appear and how they're configured:

<?php

namespace App\Livewire;

use App\Models\Product;
use Illuminate\Database\Eloquent\Builder;
use Itul\LivewireSlickFilters\SlickTable;

class ProductsTable extends SlickTable
{
    protected function query(): Builder
    {
        return Product::query();
    }

    protected function columns(): array
    {
        return [
            self::addColumn('id')
                ->filter('numeric'),

            self::addColumn('name', 'Product Name'),

            self::addColumn('sku')
                ->filter('text', 'exact'),

            self::addColumn('category')
                ->filter('select', [
                    'electronics' => 'Electronics',
                    'clothing' => 'Clothing',
                    'home' => 'Home & Garden',
                ]),

            self::addColumn('price')
                ->filter('numeric-range')
                ->display(fn($record) => '$' . number_format($record->price, 2)),

            self::addColumn('stock')
                ->filter('numeric', '>=')
                ->cellClass(function($record) {
                    return $record->stock < 10 ? 'text-danger fw-bold' : '';
                }),

            self::addColumn('created_at', 'Created')
                ->filter('date-range')
                ->display(fn($record) => $record->created_at?->format('M d, Y')),

            self::addActions([
                [
                    'label' => 'Edit',
                    'action' => 'edit',
                ],
                [
                    'label' => 'Delete',
                    'action' => 'delete',
                    'confirm' => 'Are you sure?',
                ],
            ]),
        ];
    }

    public function edit($productId)
    {
        return redirect()->route('products.edit', $productId);
    }

    public function delete($productId)
    {
        Product::findOrFail($productId)->delete();
        session()->flash('message', 'Product deleted successfully.');
    }
}

This gives you:

  • ✅ Full control over column order and visibility
  • ✅ Custom labels for each column
  • ✅ Specific filter types and operators
  • ✅ Custom display formatting (prices, dates, etc.)
  • ✅ Conditional cell styling
  • ✅ Action buttons with Livewire methods
  • ✅ Still benefits from auto-generated labels when not specified

Use it in your view:

<div>
    <livewire:products-table />
</div>

Perfect for:

  • Production applications
  • Complex tables with specific requirements
  • Tables that need custom actions, styling, or formatting
  • When you want precise control over the user experience

Artisan Commands

The package provides convenient artisan commands to scaffold table components and custom filters.

Create a Table Component

# Basic usage - creates UsersTable.php
php artisan make:slick-table Users

# With specific model
php artisan make:slick-table Products --model=Product

# Force overwrite existing file
php artisan make:slick-table Orders --model=Order --force

What it creates:

  • File: app/Livewire/UsersTable.php
  • Pre-configured with model query
  • Empty columns() array (enables auto-generation by default)
  • Commented-out column definitions showing EXACTLY what will be auto-generated
  • Smart filter inference (dates get date-range, status gets select, etc.)
  • Auto-excludes ID columns - Skips id and foreign keys (*_id)
  • Usage instructions in console output

Example output for User model:

<?php

namespace App\Livewire;

use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Itul\LivewireSlickFilters\SlickTable;

class UsersTable extends SlickTable
{
    protected function query(): Builder
    {
        return User::query();
    }

    /**
     * Define the columns for the table
     *
     * Return empty array [] to auto-generate columns from model's $fillable or database schema.
     * Uncomment columns below to customize specific columns with filters and styling.
     */
    protected function columns(): array
    {
        return [
            // Auto-generation enabled (empty array)
            // Uncomment and customize columns as needed:
            // self::addColumn('name'),
            // self::addColumn('email'),
            // self::addColumn('email_verified_at')->filter('date-range'),
            // self::addColumn('created_at')->filter('date-range'),
            // self::addColumn('updated_at')->filter('date-range'),
        ];
    }
}

Note: The command inspects your model and generates the actual column definitions you'll get from auto-generation. ID columns (id, user_id, etc.) are automatically excluded as they're rarely useful to display.

Create a Custom Filter

# Basic usage - creates ColorPickerFilter.php and ColorPickerFilter.blade.php
php artisan make:slick-filter ColorPicker

# Force overwrite existing files
php artisan make:slick-filter Slider --force

What it creates:

  • Class: app/Livewire/SlickFilters/ColorPickerFilter.php
  • View: resources/views/livewire/SlickFilters/ColorPickerFilter.blade.php
  • Implements FilterInterface with all required methods
  • Pre-configured with automatic view discovery
  • Helpful TODO comments for customization

Example class output:

<?php

namespace App\Livewire\SlickFilters;

use Illuminate\Database\Eloquent\Builder;
use Itul\LivewireSlickFilters\Filters\FilterInterface;

class ColorPickerFilter implements FilterInterface
{
    protected array $options = [];
    protected ?string $label = null;

    public static function make(array $options = []): self
    {
        return new static($options);
    }

    public function apply(Builder $query, string $field, mixed $value): Builder
    {
        // TODO: Implement your filter logic here
        return $query;
    }

    public function getType(): string
    {
        return 'color-picker';
    }

    public function getOptions(): array
    {
        return array_merge([
            'placeholder' => $this->label ? "Filter {$this->label}" : 'Filter...',
        ], $this->options);
    }
}

Next Steps After Creation:

  1. Implement filter logic in the apply() method
  2. Customize the Blade view with your filter UI
  3. Add any configuration options to getOptions()
  4. Use it in your table: self::addColumn('color')->filter(ColorPickerFilter::make([...]))

Benefits:

  • ✅ Saves time with boilerplate code
  • ✅ Ensures correct structure and interface implementation
  • ✅ Automatic view discovery configured out of the box
  • ✅ Helpful comments guide you through customization

Columns

Columns are the core building block of your table. Each column represents a field from your database.

Creating Columns

Here's a complete component example showing where the columns() method goes:

<?php

namespace App\Livewire;

use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Itul\LivewireSlickFilters\SlickTable;

class UsersTable extends SlickTable
{
    protected function query(): Builder
    {
        return User::query();
    }

    protected function columns(): array
    {
        return [
            self::addColumn('name'),        // field='name', label='Name'
            self::addColumn('email'),       // field='email', label='Email'
            self::addColumn('created_at'),  // field='created_at', label='Created At'
        ];
    }
}

The columns() method returns an array of column definitions using self::addColumn().

Auto-generated labels: The label is automatically generated from the field name if not provided:

self::addColumn('email')              // Creates: field='email', label='Email'
self::addColumn('first_name')         // Creates: field='first_name', label='First Name'
self::addColumn('email_verified_at')  // Creates: field='email_verified_at', label='Email Verified At'

Custom labels:

self::addColumn('created_at', 'Registration Date')  // Custom label

Column Precedence System

The package uses smart fallbacks for column generation:

  1. Explicit columns() method (highest priority) - If defined and returns non-empty array, uses your custom columns
    • Empty array [] triggers auto-generation - Convenient way to enable auto-generation while keeping the method
  2. Model's $fillable property - If columns() returns empty array, auto-generates from $fillable
    • Automatically excludes ID columns - Skips id and any columns ending with _id (foreign keys)
    • Automatically excludes hidden fields - Skips any fields in the model's $hidden property
  3. Database schema (final fallback) - If no $fillable, inspects the database table
    • Automatically excludes ID columns - Skips id and any columns ending with _id (foreign keys)
    • Automatically excludes hidden fields - Skips any fields in the model's $hidden property

Auto-excluded columns:

  • id - Primary key (rarely useful to display)
  • *_id - Foreign keys (e.g., user_id, category_id)
  • password - Sensitive data (also typically in $hidden)
  • remember_token - Sensitive data (also typically in $hidden)
  • Any fields in model's $hidden property - e.g., API tokens, secrets, etc.

Example:

// Empty array triggers auto-generation:
protected function columns(): array
{
    return []; // Auto-generates (excludes id, *_id, password, remember_token, and $hidden fields)
}

// Example: User model with $hidden property
class User extends Model
{
    protected $fillable = ['name', 'email', 'password', 'api_token'];
    protected $hidden = ['password', 'api_token', 'remember_token'];
}

// Auto-generated columns will only include: name, email
// Excluded: id (primary key), password ($hidden), api_token ($hidden), remember_token ($hidden)

// Or define custom columns:
protected function columns(): array
{
    return [
        self::addColumn('id')->filter('numeric'), // Manually add id if needed
        self::addColumn('name'),
        self::addColumn('user_id')->filter('numeric'), // Manually add foreign key if needed
    ]; // Non-empty - uses these columns exactly as defined
}

Methods

Basic

field(string $field)

Specify a different database field name (useful for relationships):

self::addColumn('user_name', 'User')
    ->field('user.name')
sortable(bool $sortable = true)

Control sorting for this column. Columns are sortable by default, so you only need to call this to disable sorting:

// Sortable by default - no need to call ->sortable()
self::addColumn('name')  // Already sortable

// Disable sorting
self::addColumn('actions')->sortable(false)

// Explicitly enable (redundant but allowed)
self::addColumn('name')->sortable()

Modifiers

The Column class provides several helper methods useful for custom views, conditional logic, and dynamic rendering.

hasFilter(): bool

Check if a column has an active filter (excluding explicitly disabled filters).

// In a custom view or component
@foreach($columns as $column)
    @if($column->hasFilter())
        {{-- Render filter input --}}
        <div class="filter-input">
            {{ $column->getLabel() }}: {{ $column->getFilter()->getType() }}
        </div>
    @endif
@endforeach

Use cases:

  • Conditional rendering in custom views
  • Determining which columns to include in export
  • Building custom filter UI
  • Debugging column configuration
hasDisplay(): bool

Check if a column has a custom display callback defined via ->display().

// In a custom table rendering loop
@foreach($columns as $column)
    <td>
        @if($column->hasDisplay())
            {{-- Use custom display --}}
            {!! $column->renderValue($record) !!}
        @else
            {{-- Show raw value --}}
            {{ $record->{$column->getField()} }}
        @endif
    </td>
@endforeach

Use cases:

  • Custom table renderers
  • Conditional HTML escaping (custom display returns HTML, raw values need escaping)
  • Building export functionality (skip HTML in exports)
  • Creating print views
renderValue(mixed $record): mixed

Renders the column value for a record, applying the custom display callback if one exists, otherwise returns the raw field value.

// Render a specific column value
$nameColumn = self::addColumn('name')
    ->display(fn($r) => '<strong>' . $r->name . '</strong>');

echo $nameColumn->renderValue($user);  // Outputs: <strong>John Doe</strong>

// Without display callback
$emailColumn = self::addColumn('email');
echo $emailColumn->renderValue($user);  // Outputs: john@example.com

Use cases:

  • Custom table views
  • Generating reports or exports
  • Creating custom column rendering logic
  • Ensuring consistent value formatting

Note: This method respects the ->display() callback if defined, ensuring consistent rendering across your application.

getVisibleActions(mixed $record): array

Gets the list of actions that should be visible for a specific record, filtering based on the visible callback if defined.

// In a custom actions renderer
$actionsColumn = self::addActions([
    [
        'label' => 'Edit',
        'action' => 'edit',
    ],
    [
        'label' => 'Delete',
        'action' => 'delete',
        'visible' => fn($record) => $record->role !== 'admin',
    ],
]);

// Get visible actions for a user
$visibleActions = $actionsColumn->getVisibleActions($user);
// Returns only 'Edit' if user is admin, both 'Edit' and 'Delete' otherwise

// Render custom actions dropdown
foreach ($visibleActions as $action) {
    echo '<a href="#" wire:click="' . $action['action'] . '(' . $record->id . ')">';
    echo $action['label'];
    echo '</a>';
}

Use cases:

  • Custom action dropdowns or buttons
  • Building permission-based action menus
  • Creating custom table actions UI
  • Generating action audit logs

Other useful Column methods:

$column->getLabel()     // Get column label
$column->getName()      // Get column name/key
$column->getField()     // Get database field name
$column->getFilter()    // Get filter instance
$column->isSortable()   // Check if sortable
$column->isActionsColumn()  // Check if this is an actions column

Adding Actions

Create an actions column with a dropdown menu containing row-specific actions.

Method: addActions(array $actions, ?string $label = 'Actions')

Displays an ellipsis (⋮) icon that opens a menu when clicked:

protected function columns(): array
{
    return [
        self::addColumn('id'),
        self::addColumn('name'),
        self::addColumn('email'),

        // Basic actions
        self::addActions([
            [
                'label' => 'Edit',
                'action' => 'edit',  // Calls $this->edit($id)
            ],
            [
                'label' => 'Delete',
                'action' => 'delete',
                'confirm' => 'Are you sure?',  // Optional confirmation
            ],
        ]),
    ];
}

// Define the Livewire methods
public function edit($userId)
{
    return redirect()->route('users.edit', $userId);
}

public function delete($userId)
{
    User::findOrFail($userId)->delete();
}

With icons and styling:

self::addActions([
    [
        'label' => 'View',
        'action' => 'view',
        'icon' => '<svg>...</svg>',
        'class' => 'text-info',
    ],
    [
        'label' => 'Edit',
        'action' => 'edit',
        'icon' => '<svg>...</svg>',
        'class' => 'text-primary',
    ],
    [
        'label' => 'Delete',
        'action' => 'delete',
        'icon' => '<svg>...</svg>',
        'class' => 'text-danger',
        'confirm' => 'Are you sure you want to delete this item?',
    ],
])

Conditional actions (visibility based on record data):

self::addActions([
    [
        'label' => 'Activate',
        'action' => 'activate',
        'visible' => fn($record) => $record->status === 'inactive',
    ],
    [
        'label' => 'Deactivate',
        'action' => 'deactivate',
        'visible' => fn($record) => $record->status === 'active',
    ],
    [
        'label' => 'Delete',
        'action' => 'delete',
        'visible' => fn($record) => $record->role !== 'admin',
        'confirm' => 'Are you sure?',
    ],
])

Action options:

  • label (required) - Text to display in the menu
  • action (required) - Livewire method name to call
  • icon (optional) - HTML/SVG icon to display before the label
  • class (optional) - CSS classes for styling (e.g., 'text-danger')
  • confirm (optional) - Confirmation message before executing action
  • visible (optional) - Boolean or callback to conditionally show action

Features:

  • Automatically excludes from sorting and filtering
  • Supports multiple actions per row
  • Click-outside-to-close functionality
  • Confirmation dialogs for destructive actions
  • Conditional visibility based on record data
  • Custom icons and styling
  • Works seamlessly with Livewire

Relationships

You can display and interact with relationship data in your table columns using several approaches.

Best Practices

  1. Eager load relationships to avoid N+1 queries:

    protected function query()
    {
     return Post::with(['user', 'category']);
    }
    
  2. Use joins for sortable relationship columns - ensures efficient querying

  3. Combine approaches - Use joins for sortable columns and display() for complex formatting:

    self::addColumn('user_name', 'User')
     ->field('users.name')
     ->display(fn($record) => '<strong>' . $record->user_name . '</strong>')
     ->sortable(true)
    
  4. Consider performance - Joins are efficient for sorting/filtering, but eager loading is better for display-only

Display-Only Relationships

Use the display() callback to show relationship data without sorting/filtering:

protected function columns(): array
{
    return [
        self::addColumn('user', 'User')
            ->display(fn($record) => $record->user?->name ?? 'N/A')
            ->sortable(false),

        self::addColumn('category', 'Category')
            ->display(fn($record) => $record->category?->name)
            ->sortable(false),

        self::addColumn('posts_count', 'Posts')
            ->display(fn($record) => $record->posts->count())
            ->sortable(false)
            ->filter(false),
    ];
}

Pros:

  • Simple and straightforward
  • No query modifications needed
  • Works with any relationship

Cons:

  • Not sortable or filterable by default
  • Can cause N+1 queries (use eager loading)

Sortable Relationship Columns

For sortable relationship columns, use joins and specify the field:

protected function columns(): array
{
    return [
        self::addColumn('user_name', 'User')
            ->field('users.name')
            ->sortable(true),

        self::addColumn('category_name', 'Category')
            ->field('categories.name')
            ->sortable(true),
    ];
}

protected function query()
{
    return Post::query()
        ->join('users', 'posts.user_id', '=', 'users.id')
        ->join('categories', 'posts.category_id', '=', 'categories.id')
        ->select('posts.*', 'users.name as user_name', 'categories.name as category_name');
}

Pros:

  • Fully sortable and filterable
  • Efficient single query
  • Full control over the data

Cons:

  • Requires manual joins
  • More setup code

Using Accessors and Aggregates

Laravel's withCount, withAvg, withSum, etc. work great for relationship aggregates:

protected function query()
{
    return User::query()
        ->withCount('posts')
        ->withAvg('posts', 'rating')
        ->withSum('orders', 'total');
}

protected function columns(): array
{
    return [
        self::addColumn('name'),
        self::addColumn('email'),

        self::addColumn('posts_count', 'Total Posts')
            ->sortable(true),

        self::addColumn('posts_avg_rating', 'Avg Rating')
            ->display(fn($record) => number_format($record->posts_avg_rating, 2))
            ->sortable(true),

        self::addColumn('orders_sum_total', 'Total Sales')
            ->display(fn($record) => '$' . number_format($record->orders_sum_total, 2))
            ->sortable(true),
    ];
}

Pros:

  • Sortable without joins
  • Clean and Laravel-native
  • Efficient for aggregates

Cons:

  • Limited to aggregate functions
  • Can't filter by relationship attributes easily

Hidden Relationship Columns for Filtering

Use hidden columns to enable filtering/sorting by relationships without displaying them:

protected function columns(): array
{
    return [
        self::addColumn('id'),
        self::addColumn('title'),

        // Hidden column for filtering/sorting only
        self::addColumn('user_id', 'Author')
            ->hidden()
            ->filter('select', [
                1 => 'John Doe',
                2 => 'Jane Smith',
                3 => 'Bob Johnson',
            ]),

        // Display the actual user name
        self::addColumn('author', 'Author')
            ->display(fn($record) => $record->user->name)
            ->sortable(false)
            ->filter(false),
    ];
}

Pros:

  • Clean separation of filtering and display
  • Leverages foreign keys for efficient queries
  • No joins needed for basic filtering

Cons:

  • Requires two columns (one hidden, one visible)
  • Filter shows IDs or requires manual mapping

Filters

Filters allow users to narrow down the data displayed in your table. Each column can have a filter.

Defaults

  • Filter types are automatically inferred - integer → numeric, timestamp → date, enum → select, etc.
  • Every column is sortable by default
  • All columns with filters are automatically included in global search
  • Column labels are auto-generated from field names
  • You only need to call ->filter() to override the inferred filter type or customize behavior
  • Use ->filter(false) to disable filtering (and exclude from global search)

Automated

The package intelligently infers filter types - you don't need to specify them! It uses a smart precedence system:

Inference Precedence Order

  1. Explicit ->filter() call (Highest Priority) - Your explicit choice
  2. Model's $casts attribute - Respects your model's type casting
  3. Database schema inspection - Infers from actual column type
  4. Default text filter (Fallback) - If all else fails

Model Cast Types → Filters

Model CastInferred FilterExample
boolean, boolSelect (Yes/No)'is_active' => 'boolean'
integer, intNumeric'age' => 'integer'
decimal, float, doubleNumeric'price' => 'decimal:2'
dateDate'birth_date' => 'date'
datetime, timestampDate'published_at' => 'datetime'
immutable_date, immutable_datetimeDateLaravel 8+ immutable dates
array, json, collection, objectNo filterArrays/JSON aren't filterable
encryptedNo filterEncrypted data can't be filtered
stringText'name' => 'string'

Database Schema → Filters

If no cast is defined, falls back to database schema:

Database TypeInferred Filter
int, bigint, smallint, tinyintNumeric filter
decimal, float, doubleNumeric filter
dateDate filter
datetime, timestampDate filter
booleanSelect filter (Yes/No)
enumSelect filter with enum values
varchar, text, charText filter

Example with Model Casts:

// Your Model:
class Product extends Model
{
    protected $casts = [
        'price' => 'decimal:2',      // Will get numeric filter
        'is_featured' => 'boolean',   // Will get select filter (Yes/No)
        'published_at' => 'datetime', // Will get date filter
        'metadata' => 'array',        // Will NOT get a filter (array can't be filtered)
    ];
}

// Your Table:
protected function columns(): array
{
    return [
        self::addColumn('id'),           // Auto: numeric filter (int in DB)
        self::addColumn('name'),         // Auto: text filter (varchar in DB)
        self::addColumn('price'),        // Auto: numeric filter (from $casts!)
        self::addColumn('is_featured'),  // Auto: select filter (from $casts!)
        self::addColumn('published_at'), // Auto: date filter (from $casts!)
        self::addColumn('metadata'),     // Auto: NO filter (array in $casts)
        self::addColumn('status'),       // Auto: select filter if enum in DB
    ];
}

Disabling Filters

If you don't want a filter on a specific column, pass false to the filter() method:

self::addColumn('actions', 'Actions')
    ->filter(false); // No filter for this column

self::addColumn('avatar', 'Avatar')
    ->filter(false);

Note: You can also use ->withoutFilter() or ->noFilter() methods if you prefer.

Overriding

Even though filters are inferred automatically, you can override them:

self::addColumn('status') // Auto: inferred from DB
    ->filter('select', [   // Override with custom select options
        'active' => 'Active',
        'inactive' => 'Inactive',
        'pending' => 'Pending',
    ])

Placeholders

Filters automatically generate intelligent placeholders based on your column labels. You don't need to manually specify placeholders in most cases.

How it works:

  • Non-range filters (text, numeric, date, select) → "{Label} Search"
  • Range filters (date-range, numeric-range) → "From {Label}" and "To {Label}"

Examples:

// Text filter automatically gets "{Label} Search" placeholder
self::addColumn('email')  // Placeholder: "Email Search"
self::addColumn('status') // Placeholder: "Status Search"

// Numeric filter automatically gets "{Label} Search" placeholder
self::addColumn('price')
    ->filter('numeric')  // Placeholder: "Price Search"

// Date range automatically gets "From {Label}" and "To {Label}" placeholders
self::addColumn('created_at', 'Registration Date')
    ->filter('date-range')  // Placeholders: "From Registration Date", "To Registration Date"

// Numeric range automatically gets "From {Label}" and "To {Label}" placeholders
self::addColumn('price', 'Price Range')
    ->filter('numeric-range')  // Placeholders: "From Price Range", "To Price Range"

Fallback behavior:

If no label is provided, filters use generic defaults:

  • Text/Numeric/Date: "Search...", "Enter number...", "Select date..."
  • Range filters: "Start date...", "End date...", "Min...", "Max..."

Custom placeholders still work:

You can always override the smart placeholders by providing your own:

self::addColumn('email')
    ->filter('text', ['placeholder' => 'Type email address...'])  // Overrides "Email Search"

self::addColumn('price')
    ->filter('numeric-range', [
        'placeholder_min' => 'Minimum $',  // Overrides "From Price"
        'placeholder_max' => 'Maximum $',  // Overrides "To Price"
    ])

Filter Types

The package supports a simplified, intuitive filter syntax.

Text

Supports multiple operators with both case-insensitive (like-*) and case-sensitive variants, including negation (not-*):

  • Case-insensitive: like-contains (default), like, like-starts-with, like-ends-with
  • Case-insensitive NOT: not-like-contains, not-like, not-like-starts-with, not-like-ends-with
  • Case-sensitive: contains, exact, starts-with, ends-with
  • Case-sensitive NOT: not-contains, not-exact, not-starts-with, not-ends-with
// Default text filter is automatically applied, no need to call ->filter()
// Placeholder automatically becomes "Name Search"
self::addColumn('name', 'Name')

// Customize text filter with operator
// Placeholder still automatically becomes "Name Search"
self::addColumn('name', 'Name')
    ->filter('text', 'exact');

// Override automatic placeholder with custom one
self::addColumn('name', 'Name')
    ->filter('text', ['placeholder' => 'Search name...']);  // Overrides "Name Search"

// Case-insensitive operators (like-*)
self::addColumn('email')
    ->filter('text', 'like-contains'); // Default - finds "john" in "John.Doe@example.com"

self::addColumn('status')
    ->filter('text', 'like'); // Exact match - "active" matches "ACTIVE"

self::addColumn('name')
    ->filter('text', 'like-starts-with'); // "joh" matches "John" or "JOHN"

self::addColumn('domain')
    ->filter('text', 'like-ends-with'); // ".com" matches ".COM"

// Case-sensitive operators
self::addColumn('username')
    ->filter('text', 'contains'); // "John" in "JohnDoe" but not "john"

self::addColumn('code')
    ->filter('text', 'exact'); // "ABC123" only matches "ABC123", not "abc123"

// Negation operators (NOT)
self::addColumn('email')
    ->filter('text', 'not-like-contains'); // Exclude emails containing "spam"

self::addColumn('username')
    ->filter('text', 'not-contains'); // Exclude usernames containing "test" (case-sensitive)

Numeric

Supports comparison operators: =, != (or not-=), >, >=, <, <=

// Simple - automatic placeholder: "Age Search"
self::addColumn('age', 'Age')
    ->filter('numeric');

// With operator - automatic placeholder: "Price Search"
self::addColumn('price', 'Price')
    ->filter('numeric', '>=');

// Override automatic placeholder and add options
self::addColumn('quantity', 'Quantity')
    ->filter('numeric', '>=', [
        'placeholder' => 'Min quantity...',  // Overrides "Quantity Search"
        'min' => 0,
        'step' => 1,
    ]);

Date

Filter by exact date with comparison operators: =, != (or not-=), >, >=, <, <=

// Simple - automatic placeholder: "Created Date Search"
self::addColumn('created_at', 'Created Date')
    ->filter('date');

// With operator - automatic placeholder: "Published Search"
self::addColumn('published_at', 'Published')
    ->filter('date', '>=');

// Override automatic placeholder
self::addColumn('expires_at', 'Expires')
    ->filter('date', '<=', ['placeholder' => 'Before date...']);  // Overrides "Expires Search"

Date Range

Filter between two dates:

// Simple - automatic placeholders: "From Created Date" and "To Created Date"
self::addColumn('created_at', 'Created Date')
    ->filter('date-range');

// Override automatic placeholders
self::addColumn('created_at', 'Created Date')
    ->filter('date-range', [
        'placeholder_start' => 'Start date...',  // Overrides "From Created Date"
        'placeholder_end' => 'End date...',      // Overrides "To Created Date"
    ]);

Numeric Range

Filter between two numeric values:

// Simple - automatic placeholders: "From Price" and "To Price"
self::addColumn('price', 'Price')
    ->filter('numeric-range');

// Override automatic placeholders and add options
self::addColumn('price', 'Price')
    ->filter('numeric-range', [
        'placeholder_min' => 'Min price...',  // Overrides "From Price"
        'placeholder_max' => 'Max price...',  // Overrides "To Price"
        'min' => 0,
        'step' => 0.01,
    ]);

Select (Dropdown)

Filter using a dropdown/select input with predefined options:

// Simple dropdown - automatic placeholder: "Status Search"
self::addColumn('status', 'Status')
    ->filter('select', [
        'active' => 'Active',
        'inactive' => 'Inactive',
        'pending' => 'Pending',
    ]);

// Override automatic placeholder
self::addColumn('role', 'Role')
    ->filter('select', [
        'admin' => 'Administrator',
        'user' => 'User',
        'guest' => 'Guest',
    ], '=', ['placeholder' => 'Select role...']);  // Overrides "Role Search"

// Multiple selection dropdown
self::addColumn('tags', 'Tags')
    ->filter('select', [
        'php' => 'PHP',
        'laravel' => 'Laravel',
        'livewire' => 'Livewire',
    ], 'in', ['multiple' => true]);

// Simple array (value used as both key and label)
self::addColumn('department', 'Department')
    ->filter('select', ['Sales', 'Marketing', 'Engineering', 'HR']);

// Exclude specific values (not-equal)
self::addColumn('status', 'Status')
    ->filter('select', [
        'active' => 'Active',
        'archived' => 'Archived',
    ], 'not-='); // or use '!='

// Exclude multiple values (not-in)
self::addColumn('status', 'Status')
    ->filter('select', [
        'draft' => 'Draft',
        'archived' => 'Archived',
        'deleted' => 'Deleted',
    ], 'not-in', ['multiple' => true]); // or use 'not_in'

Advanced: Using Filter Classes Directly

For more complex scenarios, you can still use filter classes directly:

use Itul\LivewireSlickFilters\Filters\TextFilter;
use Itul\LivewireSlickFilters\Filters\SelectFilter;

self::addColumn('name', 'Name')
    ->filter(TextFilter::make('like', ['placeholder' => 'Search...']));

self::addColumn('status', 'Status')
    ->filter(SelectFilter::make(['active' => 'Active'], '=', ['placeholder' => 'All']));

Advanced: Create a Custom Filter

Need a specialized filter type like a color picker, slider, or autocomplete? You can easily create custom filters!

Quick Start with Artisan:

# Create a custom filter in seconds
php artisan make:slick-filter ColorPicker

# Creates:
# - app/Livewire/SlickFilters/ColorPickerFilter.php
# - resources/views/livewire/SlickFilters/ColorPickerFilter.blade.php

The command scaffolds:

  • ✅ Filter class implementing FilterInterface
  • ✅ Blade view with example UI
  • ✅ Automatic view discovery configured
  • ✅ Helpful TODO comments

Next steps:

  1. Implement filter logic in the apply() method
  2. Customize the Blade view UI
  3. Use it: self::addColumn('color')->filter(ColorPickerFilter::make([...]))

Example custom filter class:

<?php

namespace App\Livewire\SlickFilters;

use Illuminate\Database\Eloquent\Builder;
use Itul\LivewireSlickFilters\Filters\FilterInterface;

class ColorPickerFilter implements FilterInterface
{
    protected array $colors = [];

    public function __construct(array $colors = [])
    {
        $this->colors = $colors;
    }

    public static function make(array $colors = []): self
    {
        return new static($colors);
    }

    public function apply(Builder $query, string $field, mixed $value): Builder
    {
        if (empty($value)) {
            return $query;
        }

        return $query->where($field, '=', $value);
    }

    public function getType(): string
    {
        return 'color-picker'; // View auto-discovered at livewire.SlickFilters.ColorPickerFilter
    }

    public function getOptions(): array
    {
        return ['colors' => $this->colors];
    }
}

Using your custom filter:

use App\Livewire\SlickFilters\ColorPickerFilter;

protected function columns(): array
{
    return [
        self::addColumn('color')->filter(ColorPickerFilter::make([
            '#ff0000' => 'Red',
            '#00ff00' => 'Green',
            '#0000ff' => 'Blue',
        ])),
    ];
}

Learn more:

Customizing Display

Now that you understand columns and filters, let's learn how to customize how data is displayed.

display(callable $callback)

Customize how the column value is displayed. The callback receives the full record/row object, allowing you to format values or create rich HTML output:

// Simple value formatting
self::addColumn('price', 'Price')
    ->display(fn($record) => '$' . number_format($record->price, 2))

// Date formatting
self::addColumn('created_at', 'Created')
    ->display(fn($record) => $record->created_at?->format('M d, Y'))

// Combine multiple fields
self::addColumn('name', 'Full Name')
    ->display(function($record) {
        return '<strong>' . $record->first_name . ' ' . $record->last_name . '</strong>';
    })

// Add HTML links
self::addColumn('email', 'Email')
    ->display(function($record) {
        return '<a href="mailto:' . $record->email . '">' . $record->email . '</a>';
    })

// Conditional rendering with styles
self::addColumn('status', 'Status')
    ->display(function($record) {
        $color = $record->status === 'active' ? '#10b981' : '#ef4444';
        return '<span style="color: ' . $color . ';">' . ucfirst($record->status) . '</span>';
    })

// Create action buttons
self::addColumn('actions', 'Actions')
    ->filter(false)
    ->sortable(false)
    ->display(function($record) {
        return '
            <button wire:click="edit(' . $record->id . ')">Edit</button>
            <button wire:click="delete(' . $record->id . ')">Delete</button>
        ';
    })

Note: If you don't use ->display(), the raw value from the database will be rendered.

cellClass(string|callable $classes)

Add CSS classes to the <td> element for a specific column. Can be either a static string or a callback for conditional classes:

// Static classes - applied to all cells in this column
self::addColumn('price')
    ->cellClass('fw-bold text-end')
    ->display(fn($record) => '$' . number_format($record->price, 2))

self::addColumn('email')
    ->cellClass('font-monospace small')

// Conditional classes - based on record data
self::addColumn('status')
    ->cellClass(function($record) {
        return match($record->status) {
            'active' => 'bg-success text-white',
            'inactive' => 'bg-danger text-white',
            'pending' => 'bg-warning text-dark',
            default => '',
        };
    })

self::addColumn('stock')
    ->cellClass(function($record) {
        if ($record->stock === 0) {
            return 'text-danger fw-bold';
        } elseif ($record->stock < 10) {
            return 'text-warning';
        }
        return '';
    })

Common use cases:

  • Text alignment (text-start, text-end, text-center)
  • Typography (fw-bold, font-monospace, text-uppercase)
  • Color coding based on values (positive/negative, status)
  • Background colors for specific cells
  • Combining with ->display() for full cell customization

rowClass(mixed $record): string

Add conditional CSS classes to table rows based on the record data. Override this method in your component to dynamically style rows:

class UsersTable extends SlickTable
{
    // ... columns and query methods ...

    protected function rowClass($record): string
    {
        return match($record->status) {
            'active' => 'bg-green-50',
            'inactive' => 'bg-red-50 opacity-50',
            'pending' => 'bg-yellow-50',
            default => '',
        };
    }
}

Multiple conditions example:

protected function rowClass($record): string
{
    $classes = [];

    // Highlight overdue items
    if ($record->due_date && $record->due_date->isPast()) {
        $classes[] = 'bg-red-100 border-l-4 border-red-600';
    }

    // Highlight new items
    if ($record->created_at->isToday()) {
        $classes[] = 'bg-blue-50';
    }

    // Fade out inactive
    if ($record->status === 'inactive') {
        $classes[] = 'opacity-60';
    }

    return implode(' ', $classes);
}

Common use cases:

  • Highlight rows by status (active/inactive/pending)
  • Show priority levels (high/medium/low)
  • Fade out soft-deleted or archived records
  • Highlight overdue, new, or recently updated records
  • Add visual indicators for important data

Choosing the Right Approach

Now that you understand the basics, here's how to choose the right approach for your needs:

Approach Comparison

ApproachCode RequiredCustomizationBest For
Generic Blade ComponentNone (Blade only)Limited (props only)Quick admin panels, prototyping
Custom Component (Zero Config)Minimal (just query)Medium (override methods)Standard CRUD with some custom logic
Custom Component (Full Config)Complete controlFullComplex tables with custom everything

Decision Tree

Do you need custom logic (actions, row styling, custom methods)?
├─ NO
│  └─ Use Generic Blade Component
│     <livewire:slick-table model="App\Models\User" />
│
└─ YES
   │
   Do you need custom columns or filters?
   ├─ NO (just need custom query/logic)
   │  └─ Use Custom Component - Zero Config
   │     class UsersTable extends SlickTable {
   │         protected function query() { ... }
   │     }
   │
   └─ YES
      │
      Need to customize ALL columns?
      ├─ NO (just a few)
      │  └─ Use Custom Component - Partial Config
      │     Use mergeColumns() to mix auto + custom
      │
      └─ YES
         └─ Use Custom Component - Full Config
            Define all columns explicitly

Quick Reference

Prototyping? → Use <livewire:slick-table model="..." />

Simple CRUD? → Extend SlickTable, just define query()

Need some customization? → Use mergeColumns()

Complex requirements? → Define all columns explicitly

Advanced Usage

Public Methods

The table component exposes public methods you can call from your views or component logic for programmatic control.

clearFilters()

Clears all active filters at once.

// In your component
public function resetAllFilters()
{
    $this->clearFilters();
    session()->flash('message', 'All filters cleared');
}
{{-- In your view --}}
<button wire:click="clearFilters">
    Reset All Filters
</button>

Use cases:

  • Custom "Reset" buttons in your UI
  • Clearing filters after a successful action
  • Resetting table state programmatically

clearFilter(string $field)

Clears a specific filter by field name.

// In your component
public function clearStatusFilter()
{
    $this->clearFilter('status');
}

public function resetPriceRange()
{
    $this->clearFilter('price');
}
{{-- In your view --}}
<button wire:click="clearFilter('status')">
    Clear Status Filter
</button>

<button wire:click="clearFilter('price')">
    Reset Price
</button>

Use cases:

  • Custom filter clear buttons
  • Conditional filter clearing based on other actions
  • Creating custom filter UI controls

Note: These methods are in addition to the automatic active filter badges that appear above the table. Use these methods when you need custom buttons or programmatic control.

Lifecycle Hooks

The table component provides lifecycle hooks you can override to add custom logic when filters, search, or pagination changes. These methods are automatically triggered by Livewire when the corresponding properties update.

updatedFilters()

Called whenever any filter value changes. Automatically resets pagination to page 1.

class UsersTable extends SlickTable
{
    protected function updatedFilters()
    {
        // Custom logic after filters change

        // Example: Track analytics
        Log::info('Filters changed', ['filters' => $this->filters]);

        // Example: Show notification
        session()->flash('info', 'Filters applied');

        // Example: Custom validation
        if (isset($this->filters['age']) && $this->filters['age'] > 100) {
            $this->filters['age'] = 100;
            session()->flash('warning', 'Age capped at 100');
        }
    }
}

Common use cases:

  • Analytics tracking
  • Logging filter changes
  • Custom validation
  • Showing notifications
  • Triggering side effects (updating charts, exporting data, etc.)

updatedSearch()

Called whenever the global search value changes. Automatically resets pagination to page 1.

class UsersTable extends SlickTable
{
    protected function updatedSearch()
    {
        // Custom logic after search changes

        // Example: Track search queries
        if (!empty($this->search)) {
            SearchLog::create([
                'query' => $this->search,
                'user_id' => auth()->id(),
                'table' => 'users',
            ]);
        }

        // Example: Minimum search length
        if (strlen($this->search) < 3 && !empty($this->search)) {
            session()->flash('warning', 'Please enter at least 3 characters');
        }
    }
}

Common use cases:

  • Search analytics
  • Logging search queries
  • Minimum length validation
  • Search suggestions
  • Autocomplete triggers

updatedPerPage()

Called whenever the per-page value changes. Automatically resets pagination to page 1.

class UsersTable extends SlickTable
{
    protected function updatedPerPage()
    {
        // Custom logic after per-page changes

        // Example: Track user preferences
        if (auth()->check()) {
            auth()->user()->update([
                'preferred_table_size' => $this->perPage,
            ]);
        }

        // Example: Log preference changes
        Log::info('User changed per-page setting', [
            'per_page' => $this->perPage,
            'user_id' => auth()->id(),
        ]);
    }
}

Common use cases:

  • Saving user preferences
  • Analytics tracking
  • Adjusting lazy loading thresholds
  • Performance monitoring

Important notes:

  • All three methods automatically reset pagination to page 1 (unless you override this behavior)
  • Methods are called AFTER the property value has been updated
  • You can call parent method if you want to keep default behavior: parent::updatedFilters();
  • These are standard Livewire lifecycle hooks - see Livewire documentation for more details

Creating Custom Filters

While the package provides 6 built-in filter types, you can create custom filters for specialized input types like color pickers, slider ranges, autocomplete fields, or any other custom UI element.

📦 Ready-to-use examples available! Check out the examples/Filters/ directory for complete working examples:

  • ColorPickerFilter - Visual color picker with swatches (view code)
  • SliderFilter - Range slider for numeric values (view code)
  • Complete guide - Step-by-step setup instructions (view guide)

Custom filters are useful for:

  • Specialized input types (color pickers, date pickers with custom formats, sliders)
  • Business-specific filtering logic (fuzzy search, regex matching, custom operators)
  • Third-party UI components (Select2, Vue Select, Alpine components)
  • Complex multi-field filters (location radius, price with currency, etc.)

Understanding FilterInterface

All filters must implement the FilterInterface which requires three methods:

<?php

namespace Itul\LivewireSlickFilters\Filters;

use Illuminate\Database\Eloquent\Builder;

interface FilterInterface
{
    /**
     * Apply the filter to the query builder
     */
    public function apply(Builder $query, string $field, mixed $value): Builder;

    /**
     * Get the filter type for rendering the appropriate input
     */
    public function getType(): string;

    /**
     * Get filter configuration/options
     */
    public function getOptions(): array;
}

Required methods:

  1. apply(Builder $query, string $field, mixed $value): Builder

    • Applies the filter logic to the Eloquent query
    • Receives the query builder, field name, and filter value
    • Must return the modified query builder
    • Should skip filtering when value is empty/null
  2. getType(): string

    • Returns a unique string identifier for your filter type
    • Used to determine which Blade view component to render
    • Example: 'color-picker', 'slider', 'autocomplete'
  3. getOptions(): array

    • Returns configuration array for the filter
    • Should include defaults merged with custom options
    • Common options: placeholder, min, max, step, etc.

Optional but recommended:

  1. withLabel(string $label): self
    • Enables smart placeholder integration
    • Allows automatic placeholder generation based on column label
    • Makes filter chainable: ColorFilter::make()->withLabel('Background')

Step-by-Step Example: Color Picker Filter

Let's create a complete custom filter for color selection.

Step 1: Create the Filter Class

Create the file at app/Livewire/SlickFilters/ColorPickerFilter.php:

<?php

namespace App\Livewire\SlickFilters;

use Illuminate\Database\Eloquent\Builder;
use Itul\LivewireSlickFilters\Filters\FilterInterface;

class ColorPickerFilter implements FilterInterface
{
    protected array $colors = [];
    protected array $options = [];
    protected ?string $label = null;

    /**
     * @param array $colors Available colors ['#ff0000' => 'Red', '#00ff00' => 'Green']
     * @param array $options Additional configuration
     */
    public function __construct(array $colors = [], array $options = [])
    {
        $this->colors = $colors;
        $this->options = $options;
    }

    public static function make(array $colors = [], array $options = []): self
    {
        return new static($colors, $options);
    }

    /**
     * Enable smart placeholder integration
     */
    public function withLabel(string $label): self
    {
        $this->label = $label;
        return $this;
    }

    /**
     * Apply color filter to query
     */
    public function apply(Builder $query, string $field, mixed $value): Builder
    {
        // Skip if no value provided
        if (empty($value)) {
            return $query;
        }

        // Apply exact color match
        return $query->where($field, '=', $value);
    }

    /**
     * Return the filter type for view resolution
     */
    public function getType(): string
    {
        return 'color-picker';
    }

    /**
     * Return filter configuration
     */
    public function getOptions(): array
    {
        $defaults = [
            'placeholder' => $this->label ? "{$this->label} Search" : 'Select color...',
            'colors' => $this->colors,
            'allow_custom' => $this->options['allow_custom'] ?? false,
        ];

        return array_merge($defaults, $this->options);
    }
}

Step 2: Create the Blade View Component

Create the file at resources/views/livewire/SlickFilters/ColorPickerFilter.blade.php:

@props(['column', 'filter'])

@php
    $config = $filter->getOptions();
@endphp

<div style="display: flex; gap: 0.25rem; flex-wrap: wrap;">
    @foreach($config['colors'] as $hex => $name)
        <button
            type="button"
            wire:click="$set('filters.{{ $column->getName() }}', '{{ $hex }}')"
            title="{{ $name }}"
            style="
                width: 24px;
                height: 24px;
                border-radius: 4px;
                background-color: {{ $hex }};
                border: 2px solid {{ $this->filters[$column->getName()] === $hex ? '#000' : '#ddd' }};
                cursor: pointer;
            "
        ></button>
    @endforeach

    @if(!empty($this->filters[$column->getName()]))
        <button
            type="button"
            wire:click="$set('filters.{{ $column->getName() }}', '')"
            style="
                padding: 0 0.5rem;
                font-size: 0.75rem;
                background: #fee2e2;
                color: #991b1b;
                border: none;
                border-radius: 4px;
                cursor: pointer;
            "
        >Clear</button>
    @endif
</div>

Step 3: Use Your Custom Filter

That's it! Custom filters are automatically discovered - no registration needed.

The package automatically:

  • Checks for custom views in resources/views/livewire/SlickFilters/
  • Converts filter type 'color-picker' → view name 'ColorPickerFilter'
  • Falls back to built-in filters if custom view doesn't exist

Now use it in your table component:

<?php

namespace App\Livewire;

use App\Livewire\SlickFilters\ColorPickerFilter;
use App\Models\Product;
use Illuminate\Database\Eloquent\Builder;
use Itul\LivewireSlickFilters\SlickTable;

class ProductsTable extends SlickTable
{
    protected function query(): Builder
    {
        return Product::query();
    }

    protected function columns(): array
    {
        return [
            self::addColumn('name'),

            self::addColumn('color', 'Product Color')
                ->filter(ColorPickerFilter::make([
                    '#ff0000' => 'Red',
                    '#00ff00' => 'Green',
                    '#0000ff' => 'Blue',
                    '#ffff00' => 'Yellow',
                    '#ff00ff' => 'Magenta',
                    '#00ffff' => 'Cyan',
                ])),

            self::addColumn('price')
                ->filter('numeric', '>='),
        ];
    }
}

The filter automatically receives the column label via withLabel() for smart placeholder generation!

Alternative: Simpler Custom Filters

For simpler custom filters without UI components, you can create filters that use existing input types:

<?php

namespace App\Filters;

use Illuminate\Database\Eloquent\Builder;
use Itul\LivewireSlickFilters\Filters\FilterInterface;

class FuzzySearchFilter implements FilterInterface
{
    protected ?string $label = null;

    public static function make(): self
    {
        return new static();
    }

    public function withLabel(string $label): self
    {
        $this->label = $label;
        return $this;
    }

    public function apply(Builder $query, string $field, mixed $value): Builder
    {
        if (empty($value)) {
            return $query;
        }

        // Fuzzy search: match if 80% of characters are present
        $pattern = implode('%', str_split($value));
        return $query->where($field, 'like', "%{$pattern}%");
    }

    public function getType(): string
    {
        return 'text'; // Reuse the text input view
    }

    public function getOptions(): array
    {
        return [
            'placeholder' => $this->label ? "{$this->label} (Fuzzy)" : 'Fuzzy search...',
        ];
    }
}

This filter reuses the built-in text input type but applies custom fuzzy search logic.

Integration with Column

The Column class automatically calls withLabel() on your filter if the method exists:

// In Column.php (automatic - you don't need to do this)
if (method_exists($this->filter, 'withLabel')) {
    $this->filter->withLabel($this->label);
}

This means your custom filters automatically receive the column label for smart placeholder generation without any extra code!

More Examples

Slider Range Filter:

self::addColumn('rating', 'Customer Rating')
    ->filter(SliderFilter::make([
        'min' => 1,
        'max' => 5,
        'step' => 0.5,
    ]));

Autocomplete Filter:

self::addColumn('category')
    ->filter(AutocompleteFilter::make()
        ->endpoint('/api/categories/search')
        ->minChars(2)
    );

Multi-Select with Search:

self::addColumn('tags')
    ->filter(MultiSelectFilter::make([
        'laravel' => 'Laravel',
        'php' => 'PHP',
        'vue' => 'Vue.js',
    ])->searchable());

Best Practices

  1. Always validate input - Check for empty values in apply() method
  2. Return the query builder - Always return $query from apply()
  3. Provide defaults - Use array_merge for default options in getOptions()
  4. Implement withLabel() - Enable smart placeholder integration
  5. Document your filter - Add PHPDoc comments explaining parameters
  6. Make it chainable - Return $this from configuration methods
  7. Reuse views when possible - Use built-in filter types if UI is similar

Summary

Creating custom filters involves:

  1. Implement FilterInterface - Three required methods: apply(), getType(), getOptions()
  2. Add withLabel() - Optional but recommended for smart placeholders
  3. Create Blade component - UI for your filter (or reuse existing type)
  4. Register in table view - Add your filter type to the view logic
  5. Use in columns - Pass your filter to ->filter(YourFilter::make())

Custom filters give you complete control over both the filtering logic and the user interface!

Customizing Filter Inference

The package automatically infers filter types using a smart precedence system: explicit filter callsmodel $castsdatabase schemadefault text filter. You can customize this inference logic by overriding protected methods in your table component.

This is useful when:

  • You have custom cast types (e.g., 'money', 'phone_number', 'uuid')
  • You want custom filter logic for specific database column types
  • You need custom enum value formatting or label generation
  • You're using third-party packages that add custom casts

Understanding the Inference System

The package uses three protected methods to infer filters:

  1. inferFilterFromCast(string $field, string $castType) - Maps model cast types to filters
  2. inferFilterFromColumnType(...) - Maps database column types to filters
  3. createEnumFilter(...) - Creates select filters from enum columns

Method 1: inferFilterFromCast()

This method maps Laravel model cast types to appropriate filters.

Default mappings:

protected function inferFilterFromCast(string $field, string $castType)
{
    $baseCastType = explode(':', $castType)[0]; // Extract base type

    return match(strtolower($baseCastType)) {
        'bool', 'boolean' => SelectFilter::make(['1' => 'Yes', '0' => 'No']),
        'int', 'integer' => NumericFilter::make('='),
        'real', 'float', 'double', 'decimal' => NumericFilter::make('='),
        'date' => DateFilter::make('='),
        'datetime', 'timestamp' => DateFilter::make('='),
        'immutable_date', 'immutable_datetime' => DateFilter::make('='),
        'array', 'collection', 'json', 'object' => null, // No filter
        'encrypted' => null, // No filter
        'string' => TextFilter::make('like-contains'),
        default => null, // Fall back to schema inference
    };
}

Example: Adding custom cast type support

<?php

namespace App\Livewire;

use App\Casts\Money;
use App\Filters\MoneyFilter;
use App\Models\Product;
use Illuminate\Database\Eloquent\Builder;
use Itul\LivewireSlickFilters\Filters\NumericFilter;
use Itul\LivewireSlickFilters\SlickTable;

class ProductsTable extends SlickTable
{
    protected function query(): Builder
    {
        return Product::query();
    }

    /**
     * Override to add custom cast type support
     */
    protected function inferFilterFromCast(string $field, string $castType)
    {
        $baseCastType = explode(':', $castType)[0];

        // Check for custom cast types first
        if ($baseCastType === 'money') {
            return MoneyFilter::make(); // Your custom money filter
        }

        if ($baseCastType === 'phone') {
            return TextFilter::make('exact'); // Exact match for phone numbers
        }

        if ($baseCastType === 'uuid') {
            return TextFilter::make('exact'); // UUID exact match
        }

        // Fall back to parent implementation for standard types
        return parent::inferFilterFromCast($field, $castType);
    }

    protected function columns(): array
    {
        return [
            self::addColumn('price'),      // Uses custom MoneyFilter
            self::addColumn('phone'),      // Uses TextFilter with 'exact'
            self::addColumn('identifier'), // Uses TextFilter with 'exact'
        ];
    }
}

Use case: Third-party cast packages

protected function inferFilterFromCast(string $field, string $castType)
{
    // Support spatie/laravel-model-status package
    if ($castType === 'Spatie\\ModelStatus\\Status') {
        return SelectFilter::make([
            'pending' => 'Pending',
            'approved' => 'Approved',
            'rejected' => 'Rejected',
        ]);
    }

    // Support spatie/laravel-enum package
    if (str_contains($castType, 'Enum')) {
        $enumClass = $castType;
        if (class_exists($enumClass) && method_exists($enumClass, 'toArray')) {
            return SelectFilter::make($enumClass::toArray());
        }
    }

    return parent::inferFilterFromCast($field, $castType);
}

Method 2: inferFilterFromColumnType()

This method maps database column types to filters when no model cast is defined.

Default mappings:

protected function inferFilterFromColumnType(
    string $table,
    string $field,
    string $columnType,
    $connection
) {
    return match($columnType) {
        'bigint', 'integer', 'int', 'smallint', 'tinyint',
        'decimal', 'numeric', 'float', 'double', 'real'
            => NumericFilter::make('='),

        'date' => DateFilter::make('='),
        'datetime', 'timestamp' => DateFilter::make('='),
        'boolean', 'bool' => SelectFilter::make(['1' => 'Yes', '0' => 'No']),
        'enum' => $this->createEnumFilter($table, $field, $connection),

        default => TextFilter::make('like-contains'),
    };
}

Example: Custom database type mapping

<?php

namespace App\Livewire;

use App\Models\Order;
use Illuminate\Database\Eloquent\Builder;
use Itul\LivewireSlickFilters\Filters\NumericRangeFilter;
use Itul\LivewireSlickFilters\Filters\SelectFilter;
use Itul\LivewireSlickFilters\SlickTable;

class OrdersTable extends SlickTable
{
    protected function query(): Builder
    {
        return Order::query();
    }

    /**
     * Override to customize database type inference
     */
    protected function inferFilterFromColumnType(
        string $table,
        string $field,
        string $columnType,
        $connection
    ) {
        // Custom logic for specific column types
        if ($columnType === 'bigint' && str_ends_with($field, '_amount')) {
            // Use range filter for monetary amounts
            return NumericRangeFilter::make();
        }

        if ($columnType === 'varchar' && str_ends_with($field, '_status')) {
            // Infer status columns should be select filters
            return SelectFilter::make([
                'pending' => 'Pending',
                'processing' => 'Processing',
                'completed' => 'Completed',
                'cancelled' => 'Cancelled',
            ]);
        }

        // PostgreSQL-specific types
        if ($columnType === 'inet') {
            return TextFilter::make('exact'); // IP address exact match
        }

        if ($columnType === 'jsonb') {
            return null; // Disable filter for JSONB columns
        }

        // Fall back to parent implementation
        return parent::inferFilterFromColumnType($table, $field, $columnType, $connection);
    }

    protected function columns(): array
    {
        return [
            self::addColumn('total_amount'),   // Inferred as NumericRangeFilter
            self::addColumn('payment_status'), // Inferred as SelectFilter
            self::addColumn('ip_address'),     // Inferred as TextFilter exact
            self::addColumn('metadata'),       // No filter (jsonb)
        ];
    }
}

Method 3: createEnumFilter()

This method creates select filters from database enum columns with automatic label formatting.

Default implementation:

protected function createEnumFilter(string $table, string $field, $connection)
{
    try {
        // Get enum values from database
        $type = $connection->getDoctrineColumn($table, $field)->getType();

        if (method_exists($type, 'getValues')) {
            $values = $type->getValues();
            $options = [];
            foreach ($values as $value) {
                // Converts 'pending_review' to 'Pending Review'
                $options[$value] = ucfirst(str_replace('_', ' ', $value));
            }
            return SelectFilter::make($options);
        }
    } catch (\Exception $e) {
        // Fall back to text filter if enum inspection fails
    }

    return TextFilter::make('like-contains');
}

Example: Custom enum label formatting

<?php

namespace App\Livewire;

use App\Models\Task;
use Illuminate\Database\Eloquent\Builder;
use Itul\LivewireSlickFilters\Filters\SelectFilter;
use Itul\LivewireSlickFilters\SlickTable;

class TasksTable extends SlickTable
{
    protected function query(): Builder
    {
        return Task::query();
    }

    /**
     * Override to customize enum filter label formatting
     */
    protected function createEnumFilter(string $table, string $field, $connection)
    {
        try {
            $type = $connection->getDoctrineColumn($table, $field)->getType();

            if (method_exists($type, 'getValues')) {
                $values = $type->getValues();
                $options = [];

                foreach ($values as $value) {
                    // Custom label formatting based on field name
                    if ($field === 'priority') {
                        $options[$value] = $this->formatPriorityLabel($value);
                    } elseif ($field === 'status') {
                        $options[$value] = $this->formatStatusLabel($value);
                    } else {
                        // Default formatting
                        $options[$value] = ucfirst(str_replace('_', ' ', $value));
                    }
                }

                return SelectFilter::make($options);
            }
        } catch (\Exception $e) {
            // Graceful fallback
        }

        return TextFilter::make('like-contains');
    }

    /**
     * Custom priority label formatting with emojis
     */
    protected function formatPriorityLabel(string $value): string
    {
        return match($value) {
            'urgent' => '🔴 Urgent',
            'high' => '🟠 High',
            'medium' => '🟡 Medium',
            'low' => '🟢 Low',
            default => ucfirst($value),
        };
    }

    /**
     * Custom status label formatting
     */
    protected function formatStatusLabel(string $value): string
    {
        return match($value) {
            'not_started' => 'Not Started',
            'in_progress' => 'In Progress',
            'under_review' => 'Under Review',
            'completed' => 'Completed',
            default => ucfirst(str_replace('_', ' ', $value)),
        };
    }

    protected function columns(): array
    {
        return [
            self::addColumn('priority'), // Enum with emoji labels
            self::addColumn('status'),   // Enum with custom labels
        ];
    }
}

Complete Example: Multi-Tenant System

Combining all three methods for a complex multi-tenant application:

<?php

namespace App\Livewire;

use App\Casts\TenantId;
use App\Filters\TenantFilter;
use App\Models\Document;
use Illuminate\Database\Eloquent\Builder;
use Itul\LivewireSlickFilters\Filters\DateRangeFilter;
use Itul\LivewireSlickFilters\Filters\SelectFilter;
use Itul\LivewireSlickFilters\Filters\TextFilter;
use Itul\LivewireSlickFilters\SlickTable;

class DocumentsTable extends SlickTable
{
    protected function query(): Builder
    {
        return Document::query()->where('tenant_id', auth()->user()->tenant_id);
    }

    /**
     * Custom cast type support
     */
    protected function inferFilterFromCast(string $field, string $castType)
    {
        // Support custom TenantId cast
        if ($castType === TenantId::class) {
            return TenantFilter::make();
        }

        return parent::inferFilterFromCast($field, $castType);
    }

    /**
     * Custom database type mapping
     */
    protected function inferFilterFromColumnType(
        string $table,
        string $field,
        string $columnType,
        $connection
    ) {
        // Date fields ending with _at should use date range
        if ($columnType === 'datetime' && str_ends_with($field, '_at')) {
            return DateRangeFilter::make();
        }

        // Fields ending with _by should be user selects
        if (str_ends_with($field, '_by')) {
            return SelectFilter::make($this->getUserOptions());
        }

        return parent::inferFilterFromColumnType($table, $field, $columnType, $connection);
    }

    /**
     * Custom enum formatting with icons
     */
    protected function createEnumFilter(string $table, string $field, $connection)
    {
        try {
            $type = $connection->getDoctrineColumn($table, $field)->getType();

            if (method_exists($type, 'getValues')) {
                $values = $type->getValues();
                $options = [];

                foreach ($values as $value) {
                    if ($field === 'document_type') {
                        $options[$value] = "📄 " . ucfirst(str_replace('_', ' ', $value));
                    } else {
                        $options[$value] = ucfirst(str_replace('_', ' ', $value));
                    }
                }

                return SelectFilter::make($options);
            }
        } catch (\Exception $e) {
            // Graceful degradation
        }

        return TextFilter::make('like-contains');
    }

    protected function getUserOptions(): array
    {
        return \App\Models\User::where('tenant_id', auth()->user()->tenant_id)
            ->pluck('name', 'id')
            ->toArray();
    }

    protected function columns(): array
    {
        return [
            self::addColumn('tenant_id'),    // Uses TenantFilter
            self::addColumn('document_type'), // Enum with 📄 icons
            self::addColumn('created_at'),   // DateRangeFilter
            self::addColumn('created_by'),   // User SelectFilter
        ];
    }
}

Best Practices

  1. Always call parent method - Fall back to default behavior for standard types
  2. Handle exceptions gracefully - Use try-catch in enum methods
  3. Check field naming patterns - Leverage conventions (_at for dates, _by for users, etc.)
  4. Cache expensive operations - Store user lists, enum values if called frequently
  5. Document custom mappings - Add PHPDoc comments explaining inference logic
  6. Test edge cases - Verify behavior when columns don't exist or casts change

Summary

Filter inference customization involves three protected methods:

MethodPurposeWhen to Override
inferFilterFromCast()Map model casts to filtersCustom cast types, third-party packages
inferFilterFromColumnType()Map DB column types to filtersDatabase-specific types, field naming conventions
createEnumFilter()Generate select filters from enumsCustom enum label formatting, translations

Remember: These are advanced customization points. The default inference works for 90% of use cases!

Partial Customization with mergeColumns()

Want to customize only specific columns? Use mergeColumns() to combine auto-generated columns with your custom ones. The method automatically generates columns from your model's $fillable property or database schema, then merges them with your custom definitions:

<?php

namespace App\Livewire;

use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Itul\LivewireSlickFilters\SlickTable;

class UsersTable extends SlickTable
{
    protected function query(): Builder
    {
        return User::query();
    }

    protected function columns(): array
    {
        // Define only the columns you want to customize
        // mergeColumns() automatically generates the rest from $fillable/schema
        return $this->mergeColumns([
            self::addColumn('role')
                ->filter('select', [
                    'admin' => 'Administrator',
                    'user' => 'Regular User',
                ]),
            self::addColumn('created_at', 'Registration Date')
                ->filter('date-range')
                ->display(fn($record) => $record->created_at?->format('M d, Y')),
            self::addColumn('password')
                ->filter(false)
                ->sortable(false),
        ]);
    }
}

How it works:

  1. mergeColumns() automatically calls generateColumnsFromFillable() or generateColumnsFromSchema() internally
  2. Your custom columns override any auto-generated columns with the same name
  3. Any columns in the optional $ignore parameter are excluded from auto-generation
  4. All other columns are auto-generated with default settings
  5. This gives you the best of both worlds: automation + customization where you need it

Benefits:

  • ✅ No need to manually call generateColumnsFromFillable() or generateColumnsFromSchema()
  • ✅ Less boilerplate code
  • ✅ Customize only what you need, let the package handle the rest
  • ✅ Automatically adapts if you add/remove fields from your model's $fillable

Excluding columns from auto-generation:

You can pass a second parameter to exclude specific columns from being auto-generated:

protected function columns(): array
{
    return $this->mergeColumns(
        [
            self::addColumn('role')
                ->filter('select', [
                    'admin' => 'Administrator',
                    'user' => 'Regular User',
                ]),
        ],
        ['password', 'remember_token', 'api_token'] // Exclude these columns completely
    );
}

This is useful when you want to:

  • Hide sensitive fields without explicitly defining them with ->filter(false)
  • Prevent certain columns from appearing in the table at all
  • Exclude system/internal fields from the table

Customizing Schema-Based Column Generation

When your model doesn't have a $fillable property and you don't define a columns() method, the package falls back to inspecting the database schema. You can customize this behavior by overriding the generateColumnsFromSchema() method.

This is useful when:

  • You want to exclude additional sensitive or system fields beyond the defaults
  • You need to customize column labels or filters for schema-generated columns
  • You're working with legacy databases with unconventional column names
  • You want to exclude technical columns (metadata, soft deletes, etc.)

Understanding generateColumnsFromSchema()

Default implementation:

protected function generateColumnsFromSchema(): array
{
    try {
        $model = $this->query()->getModel();
        $table = $model->getTable();
        $connection = $model->getConnection();

        // Get all column names from the table
        $columns = Schema::connection($connection->getName())
            ->getColumnListing($table);

        if (empty($columns)) {
            return [];
        }

        // Filter out sensitive fields (security feature)
        $excludedColumns = ['password', 'remember_token'];
        $columns = array_diff($columns, $excludedColumns);

        $columnObjects = [];
        foreach ($columns as $field) {
            $columnObjects[] = Column::make($field);
        }

        return $columnObjects;
    } catch (\Exception $e) {
        return [];
    }
}

Key features:

  • Automatically excludes password and remember_token for security
  • Generates column objects from all remaining database columns
  • Gracefully handles errors (returns empty array on failure)

Example: Excluding Additional Fields

Exclude technical or system fields from auto-generated columns:

<?php

namespace App\Livewire;

use App\Models\Product;
use Illuminate\Database\Eloquent\Builder;
use Itul\LivewireSlickFilters\Support\Column;
use Itul\LivewireSlickFilters\SlickTable;

class ProductsTable extends SlickTable
{
    protected function query(): Builder
    {
        return Product::query();
    }

    /**
     * Override to exclude additional sensitive/system fields
     */
    protected function generateColumnsFromSchema(): array
    {
        try {
            $model = $this->query()->getModel();
            $table = $model->getTable();
            $connection = $model->getConnection();

            $columns = Schema::connection($connection->getName())
                ->getColumnListing($table);

            if (empty($columns)) {
                return [];
            }

            // Exclude sensitive fields, system fields, and soft deletes
            $excludedColumns = [
                'password',
                'remember_token',
                'api_key',           // API credentials
                'secret_token',      // Other secrets
                'deleted_at',        // Soft delete timestamp
                'deleted_by',        // Who deleted
                'internal_notes',    // Internal use only
                'audit_log',         // System logs
            ];
            $columns = array_diff($columns, $excludedColumns);

            $columnObjects = [];
            foreach ($columns as $field) {
                $columnObjects[] = Column::make($field);
            }

            return $columnObjects;
        } catch (\Exception $e) {
            return [];
        }
    }
}

Example: Customizing Schema-Generated Columns

Apply custom labels and filters to schema-generated columns:

<?php

namespace App\Livewire;

use App\Models\Order;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
use Itul\LivewireSlickFilters\Filters\DateRangeFilter;
use Itul\LivewireSlickFilters\Filters\SelectFilter;
use Itul\LivewireSlickFilters\Support\Column;
use Itul\LivewireSlickFilters\SlickTable;

class OrdersTable extends SlickTable
{
    protected function query(): Builder
    {
        return Order::query();
    }

    /**
     * Override to customize schema-generated columns
     */
    protected function generateColumnsFromSchema(): array
    {
        try {
            $model = $this->query()->getModel();
            $table = $model->getTable();
            $connection = $model->getConnection();

            $columns = Schema::connection($connection->getName())
                ->getColumnListing($table);

            if (empty($columns)) {
                return [];
            }

            // Exclude sensitive and system fields
            $excludedColumns = ['password', 'remember_token', 'deleted_at'];
            $columns = array_diff($columns, $excludedColumns);

            $columnObjects = [];
            foreach ($columns as $field) {
                $column = Column::make($field);

                // Customize labels for specific fields
                if ($field === 'total_amount') {
                    $column = Column::make($field, 'Order Total');
                } elseif ($field === 'customer_id') {
                    $column = Column::make($field, 'Customer');
                }

                // Apply custom filters based on field names
                if (Str::endsWith($field, '_at')) {
                    // Date fields get date range filters
                    $column->filter(DateRangeFilter::make());
                } elseif (Str::endsWith($field, '_status')) {
                    // Status fields get select filters
                    $column->filter(SelectFilter::make($this->getStatusOptions($field)));
                }

                $columnObjects[] = $column;
            }

            return $columnObjects;
        } catch (\Exception $e) {
            return [];
        }
    }

    protected function getStatusOptions(string $field): array
    {
        return match($field) {
            'payment_status' => [
                'pending' => 'Pending',
                'paid' => 'Paid',
                'failed' => 'Failed',
                'refunded' => 'Refunded',
            ],
            'order_status' => [
                'draft' => 'Draft',
                'confirmed' => 'Confirmed',
                'processing' => 'Processing',
                'shipped' => 'Shipped',
                'delivered' => 'Delivered',
                'cancelled' => 'Cancelled',
            ],
            default => [],
        };
    }
}

Example: Legacy Database Support

Handle legacy databases with non-standard column naming:

<?php

namespace App\Livewire;

use App\Models\LegacyCustomer;
use Illuminate\Database\Eloquent\Builder;
use Itul\LivewireSlickFilters\Support\Column;
use Itul\LivewireSlickFilters\SlickTable;

class LegacyCustomersTable extends SlickTable
{
    protected function query(): Builder
    {
        return LegacyCustomer::query();
    }

    /**
     * Override to handle legacy database columns
     */
    protected function generateColumnsFromSchema(): array
    {
        try {
            $model = $this->query()->getModel();
            $table = $model->getTable();
            $connection = $model->getConnection();

            $columns = Schema::connection($connection->getName())
                ->getColumnListing($table);

            if (empty($columns)) {
                return [];
            }

            // Legacy databases often have cryptic column names
            $excludedColumns = ['pwd', 'token', 'flg_deleted'];
            $columns = array_diff($columns, $excludedColumns);

            $columnObjects = [];
            foreach ($columns as $field) {
                // Map legacy column names to readable labels
                $label = $this->getLegacyColumnLabel($field);
                $column = Column::make($field, $label);

                // Hide technical fields but keep them queryable
                if ($this->isTechnicalField($field)) {
                    $column->filter(false)->sortable(false);
                }

                $columnObjects[] = $column;
            }

            return $columnObjects;
        } catch (\Exception $e) {
            return [];
        }
    }

    /**
     * Convert legacy column names to readable labels
     */
    protected function getLegacyColumnLabel(string $field): string
    {
        return match($field) {
            'cust_nm' => 'Customer Name',
            'cust_email' => 'Email Address',
            'cust_tel' => 'Phone Number',
            'dt_created' => 'Created Date',
            'dt_modified' => 'Last Modified',
            'flg_active' => 'Status',
            'amt_balance' => 'Balance',
            default => ucfirst(str_replace('_', ' ', $field)),
        };
    }

    /**
     * Identify technical fields to hide
     */
    protected function isTechnicalField(string $field): bool
    {
        return str_starts_with($field, 'sys_')
            || str_starts_with($field, 'internal_')
            || in_array($field, ['uuid', 'hash', 'checksum']);
    }
}

Example: Multi-Tenant Column Filtering

Automatically exclude tenant-specific columns:

<?php

namespace App\Livewire;

use App\Models\Document;
use Illuminate\Database\Eloquent\Builder;
use Itul\LivewireSlickFilters\Support\Column;
use Itul\LivewireSlickFilters\SlickTable;

class DocumentsTable extends SlickTable
{
    protected function query(): Builder
    {
        // Already filtered by tenant in query
        return Document::query()->where('tenant_id', auth()->user()->tenant_id);
    }

    /**
     * Override to exclude tenant columns from display
     */
    protected function generateColumnsFromSchema(): array
    {
        try {
            $model = $this->query()->getModel();
            $table = $model->getTable();
            $connection = $model->getConnection();

            $columns = Schema::connection($connection->getName())
                ->getColumnListing($table);

            if (empty($columns)) {
                return [];
            }

            // Exclude tenant columns since we're already scoping by tenant
            $excludedColumns = [
                'password',
                'remember_token',
                'tenant_id',        // Already filtered in query
                'organization_id',  // Multi-tenant field
                'workspace_id',     // Multi-tenant field
            ];
            $columns = array_diff($columns, $excludedColumns);

            $columnObjects = [];
            foreach ($columns as $field) {
                $columnObjects[] = Column::make($field);
            }

            return $columnObjects;
        } catch (\Exception $e) {
            return [];
        }
    }
}

Understanding the Fallback Chain

The complete column generation fallback chain:

// 1. Explicit columns() method (highest priority)
protected function columns(): array
{
    return [
        self::addColumn('name'),
        self::addColumn('email'),
    ];
}

// 2. Model's $fillable property
// Model: protected $fillable = ['name', 'email', 'role'];
// Automatically calls: $this->generateColumnsFromFillable()

// 3. Database schema inspection (final fallback)
// No $fillable on model
// Automatically calls: $this->generateColumnsFromSchema()

Resolution happens in SlickTable.php:

public function getColumns(): array
{
    // Check if custom columns() method defined
    if (method_exists($this, 'columns')) {
        return $this->columns();
    }

    // Try $fillable
    $fillableColumns = $this->generateColumnsFromFillable();
    if (!empty($fillableColumns)) {
        return $fillableColumns;
    }

    // Fall back to schema
    return $this->generateColumnsFromSchema();
}

Best Practices

  1. Always exclude sensitive fields - password, tokens, API keys, secrets
  2. Use array_diff for exclusions - Clean and readable approach
  3. Handle exceptions gracefully - Return empty array on failure
  4. Document custom logic - Add PHPDoc explaining your customizations
  5. Consider using $fillable instead - Define $fillable on model when possible (more maintainable)
  6. Test schema changes - Verify exclusions work when columns added/removed

When to Override generateColumnsFromSchema()

ScenarioShould Override?Alternative Approach
Exclude additional sensitive fields✅ YesDefine $fillable on model
Legacy database with cryptic names✅ YesCreate database views with better names
Multi-tenant column filtering✅ YesDefine $fillable excluding tenant columns
Customize all column labels/filters❌ NoUse explicit columns() method instead
Simple table with good column names❌ NoLet default implementation handle it

Summary

The generateColumnsFromSchema() method is a final fallback for column generation when no columns() method exists and the model has no $fillable property.

Override it to:

  • Exclude additional sensitive/system fields beyond defaults
  • Handle legacy databases with unconventional naming
  • Apply custom labels and filters to schema-generated columns
  • Filter multi-tenant or technical columns

Default behavior:

  • Excludes password and remember_token automatically (security feature)
  • Generates columns from all remaining database columns
  • Uses default filter inference for each column type

Remember: Defining $fillable on your model is usually better than overriding this method!

Custom Queries with Relationships

protected function query(): Builder
{
    return User::query()
        ->with('profile')
        ->select('users.*');
}

protected function columns(): array
{
    return [
        self::addColumn('name', 'Name'),
        self::addColumn('profile_bio', 'Bio')
            ->field('profile.bio'),
    ];
}

Advanced Query Customization

For advanced use cases, you can override the core query building methods to customize how data is fetched, filtered, and processed.

getData()

Override this method to customize the entire query building process, including how filters, search, sorting, and pagination are applied.

class UsersTable extends SlickTable
{
    protected function getData()
    {
        $columns = $this->getEnhancedColumns();
        $query = $this->query();

        // Add custom eager loading
        $query->with(['orders' => function ($q) {
            $q->where('status', 'completed');
        }]);

        // Add custom query scopes
        $query->active()->verified();

        // Add custom where clauses
        if (auth()->user()->isAdmin()) {
            // Admins see all users
        } else {
            // Regular users only see users in their department
            $query->where('department_id', auth()->user()->department_id);
        }

        // Apply filters, search, sorting (keep default behavior)
        foreach ($columns as $column) {
            if ($column->hasFilter() && isset($this->filters[$column->getName()])) {
                $filter = $column->getFilter();
                $filter->apply($query, $column->getField(), $this->filters[$column->getName()]);
            }
        }

        // Apply global search
        if (!empty($this->search)) {
            $query->where(function ($q) use ($columns) {
                foreach ($columns as $column) {
                    if ($column->hasFilter()) {
                        $q->orWhere($column->getField(), 'like', "%{$this->search}%");
                    }
                }
            });
        }

        // Apply sorting
        if ($this->sortField) {
            $query->orderBy($this->sortField, $this->sortDirection);
        }

        // Return paginated results
        return $query->paginate($this->perPage);
    }
}

Use cases:

  • Adding custom eager loading or query optimization
  • Implementing row-level security (showing different data to different users)
  • Adding complex joins or subqueries
  • Implementing soft delete filtering
  • Custom pagination logic
  • Performance optimizations (adding indexes hints, etc.)

getEnhancedColumns()

Override this method to customize how columns are enhanced with inferred filters. The method caches the enhanced columns for performance.

class UsersTable extends SlickTable
{
    protected function getEnhancedColumns(): array
    {
        // Call parent to get base columns
        $columns = $this->columns();

        // Add custom column enhancement logic
        foreach ($columns as $column) {
            // Custom filter inference for specific columns
            if ($column->getName() === 'custom_field') {
                if ($column->needsFilterInference()) {
                    $column->setInferredFilter(
                        CustomFilter::make(['option' => 'value'])
                    );
                }
            }

            // Add custom filters based on user permissions
            if ($column->getName() === 'sensitive_data' && !auth()->user()->canViewSensitiveData()) {
                $column->filter(false);
            }
        }

        // Infer filters for remaining columns (default behavior)
        foreach ($columns as $column) {
            if ($column->needsFilterInference()) {
                $this->inferFilter($column);
            }
        }

        return $columns;
    }
}

Use cases:

  • Custom filter inference logic for specific column types
  • Permission-based filter enabling/disabling
  • Dynamic column configuration based on user role
  • Adding metadata or custom properties to columns
  • Overriding filter inference for specific columns

Important notes:

  • These are advanced customization points - only override if you need custom behavior
  • Always call parent methods or replicate their logic if you need the default behavior
  • Be careful with performance - getData() is called on every table render
  • getEnhancedColumns() is cached automatically, so it's only called once per request

Query String Customization

By default, the table persists filters, search, sorting, and pagination in the URL query string. This allows users to bookmark filtered table states and share URLs with specific filters applied.

You can customize which properties are persisted by overriding the $queryString property.

Default behavior:

// This is the default - you don't need to add this
protected $queryString = [
    'filters' => ['except' => []],
    'search' => ['except' => ''],
    'sortField' => ['except' => ''],
    'sortDirection' => ['except' => ''],
    'perPage' => ['except' => 10],
];

Customization examples:

class UsersTable extends SlickTable
{
    // Example 1: Exclude specific filters from URL
    protected $queryString = [
        'filters' => ['except' => []],  // Keep filters in URL
        'search' => ['except' => ''],   // Exclude search from URL (always empty)
        'sortField',                     // Simple syntax - always in URL
        'sortDirection',
        'perPage' => ['except' => 25],  // Custom default per-page
    ];

    // Example 2: Disable all query string persistence
    protected $queryString = [];

    // Example 3: Only persist filters and search
    protected $queryString = [
        'filters',
        'search',
    ];

    // Example 4: Add custom query parameters
    protected $queryString = [
        'filters' => ['except' => []],
        'search' => ['except' => ''],
        'sortField',
        'sortDirection',
        'perPage',
        'customParam' => ['except' => 'default'],  // Your custom property
    ];

    public $customParam = 'default';
}

Use cases:

  • Shareable filtered URLs - Users can bookmark or share URLs with specific filters applied
  • Exclude sensitive filters - Remove certain filters from URL for security/privacy
  • Custom defaults - Set different default values for query parameters
  • SEO optimization - Control which parameters appear in URLs
  • Analytics tracking - Add custom parameters for tracking purposes

Important notes:

  • Query strings are a standard Livewire feature - see Livewire docs for more details
  • The except value is the default - when the property equals this value, it's removed from the URL
  • Empty arrays/strings are automatically removed from the URL
  • Be careful with sensitive data - don't persist sensitive filters in URLs
  • Query string changes trigger browser history updates

Example URLs generated:

# Default table (no filters)
/users

# With filters applied
/users?filters[status]=active&filters[role]=admin&search=john&sortField=created_at&sortDirection=desc

# With custom query string config (only filters)
/users?filters[status]=active&filters[role]=admin

Default Sorting

Set default sorting in your component:

public string $sortField = 'created_at';
public string $sortDirection = 'desc';

Default Per Page

Set the default number of records per page:

public int $perPage = 25;

Customize Per Page Options

Users can change the number of records per page using the dropdown selector in the table footer. Configure available options in your config file:

// config/slick-filters.php
'per_page_options' => [10, 25, 50, 100],
'default_per_page' => 10,

Or override per component:

class UsersTable extends SlickTable
{
    public int $perPage = 25; // Default

    public function getPerPageOptions(): array
    {
        return [5, 10, 25, 50, 100, 250]; // Custom options
    }
}

The per-page selector appears in the table footer and persists via query strings, so users can bookmark filtered URLs with their preferred page size.

Disable Search

In your config file:

'enable_search' => false,

Or per component:

// Override the render method
public function render()
{
    config(['slick-filters.enable_search' => false]);
    return parent::render();
}

Filter Behavior Reference

Understanding filter behaviors and edge cases helps avoid unexpected results and ensures compatibility across different database systems.

Empty Value Handling

All filters automatically skip application when the value is empty or null. This prevents unnecessary WHERE clauses and ensures clean queries.

// Empty values are ignored
$this->filters['status'] = '';      // Filter not applied
$this->filters['status'] = null;    // Filter not applied
$this->filters['price'] = 0;        // Filter IS applied (0 is valid)

Exception: The value 0 (zero) is considered valid and WILL apply the filter.

Why this matters:

  • Prevents errors from empty WHERE clauses
  • Cleaner SQL queries
  • Better performance (no unnecessary filtering)
  • Intuitive UX (clearing filter = empty value)

Range Filter Format Flexibility

Range filters (DateRangeFilter, NumericRangeFilter) accept multiple value formats for convenience.

Date Range Filter:

// Associative array format (recommended)
$this->filters['created_at'] = [
    'start' => '2024-01-01',
    'end' => '2024-12-31',
];

// Indexed array format (also works)
$this->filters['created_at'] = ['2024-01-01', '2024-12-31'];

// Partial ranges work too
$this->filters['created_at'] = ['start' => '2024-01-01'];  // Only start date
$this->filters['created_at'] = ['end' => '2024-12-31'];    // Only end date

Numeric Range Filter:

// Associative array format (recommended)
$this->filters['price'] = [
    'min' => 10.00,
    'max' => 100.00,
];

// Indexed array format (also works)
$this->filters['price'] = [10.00, 100.00];

// Partial ranges work too
$this->filters['price'] = ['min' => 10.00];  // Only minimum
$this->filters['price'] = ['max' => 100.00]; // Only maximum

Why this matters:

  • Flexible API for programmatic filter setting
  • Works with different input sources (forms, APIs, query strings)
  • Backwards compatible with different data structures

Date Filter and whereDate() Behavior

The DateFilter uses Laravel's whereDate() method, which strips the time component from datetime columns.

// This column definition
self::addColumn('created_at')->filter('date')

// Generates SQL like:
// WHERE DATE(created_at) = '2024-01-01'
// This matches ANY time on 2024-01-01 (00:00:00 to 23:59:59)

Important implications:

// Database has: 2024-01-01 14:30:00
// Filter value: 2024-01-01
// Result: MATCHES (time is ignored)

// Using >= operator
self::addColumn('created_at')->filter('date', '>=')
// WHERE DATE(created_at) >= '2024-01-01'
// Matches all records from 2024-01-01 00:00:00 onwards

Why this matters:

  • Intuitive date filtering (matches entire day)
  • No need to worry about time components
  • Consistent behavior across datetime and date columns
  • Performance note: DATE() function may prevent index usage on large tables

Alternative for datetime precision: If you need time-aware filtering, use DateRangeFilter or create a custom filter without whereDate().

Text Filter and BINARY Comparison

Case-sensitive text operators use MySQL's BINARY comparison. This affects database compatibility.

// Case-sensitive operators
self::addColumn('code')->filter('text', 'exact')        // Uses BINARY
self::addColumn('username')->filter('text', 'contains') // Uses BINARY
self::addColumn('name')->filter('text', 'starts-with') // Uses BINARY

Generated SQL:

-- 'exact' operator
WHERE BINARY field = 'value'

-- 'contains' operator
WHERE BINARY field LIKE '%value%'

-- 'starts-with' operator
WHERE BINARY field LIKE 'value%'

Database Compatibility:

DatabaseBINARY SupportCase-Sensitive Alternative
MySQL✅ YesNative BINARY keyword
MariaDB✅ YesNative BINARY keyword
PostgreSQL❌ NoUse COLLATE "C" or custom filter
SQLite❌ NoUse COLLATE BINARY or custom filter
SQL Server❌ NoUse COLLATE Latin1_General_CS_AS

PostgreSQL example (if needed):

// Create custom filter for PostgreSQL
$query->whereRaw("field COLLATE \"C\" = ?", [$value]);

Recommendations:

  • Use case-insensitive operators (like-*) for cross-database compatibility
  • If using PostgreSQL/SQLite, stick to like-contains, like-starts-with, etc.
  • For case-sensitive needs on PostgreSQL, create a custom filter with proper COLLATE
  • Consider your database when choosing text filter operators

Numeric Filter Type Casting

Numeric filters automatically cast values to float for consistent comparison.

// All these become floats internally
$this->filters['price'] = '50';      // Becomes 50.0
$this->filters['price'] = '50.99';   // Becomes 50.99
$this->filters['price'] = 50;        // Already 50.0

Generated SQL:

self::addColumn('price')->filter('numeric', '>=')
// WHERE price >= 50.0 (value is cast to float)

Why this matters:

  • Handles string input from forms/query strings
  • Prevents type comparison issues
  • Works with both integer and decimal database columns
  • Consistent behavior regardless of input source

Filter Application Order

Filters are applied in the order they appear in your columns definition. This can affect query performance and results with complex queries.

protected function columns(): array
{
    return [
        self::addColumn('status')->filter('select', [...]),  // Applied first
        self::addColumn('price')->filter('numeric-range'),   // Applied second
        self::addColumn('created_at')->filter('date-range'), // Applied third
    ];
}

Performance tip: Put the most selective filters first (filters that reduce the dataset the most).

Global Search Behavior

Global search uses LIKE with wildcards and applies to ALL columns with filters.

// User searches for "john"
// Generated SQL (simplified):
WHERE (
    name LIKE '%john%' OR
    email LIKE '%john%' OR
    status LIKE '%john%' OR
    ...
)

Important notes:

  • Global search is always case-insensitive LIKE (not BINARY)
  • Only columns with filters are searched
  • Uses OR logic (matches if ANY column contains the search term)
  • Applied after individual column filters (AND logic)
  • Can be slow on large tables (use indexes on searchable columns)

Performance optimization: If global search is slow, consider:

  • Adding database indexes on frequently searched columns
  • Limiting searchable columns via ->filter(false)
  • Implementing full-text search for large text columns
  • Using database-specific search features (PostgreSQL tsvector, MySQL FULLTEXT)

Customization

Customize Views

The package uses modular Blade views for maximum flexibility. You can customize any part of the UI.

Publish Views

php artisan vendor:publish --tag=slick-filters-views

This publishes views to resources/views/vendor/slick-filters/:

components/
├── table.blade.php          # Main table layout
└── filters/
    ├── text.blade.php       # Text filter input
    ├── numeric.blade.php    # Numeric filter input
    ├── date.blade.php       # Date filter input
    ├── date-range.blade.php # Date range filter
    ├── numeric-range.blade.php # Numeric range filter
    └── select.blade.php     # Select/dropdown filter

Customize Individual Filters

Each filter type is a separate component, making it easy to customize:

{{-- resources/views/vendor/slick-filters/components/filters/text.blade.php --}}
<input
    type="text"
    wire:model.live.debounce.300ms="filters.{{ $column->getName() }}"
    placeholder="{{ $filter->getOptions()['placeholder'] ?? 'Filter...' }}"
    class="your-custom-class"
/>

Override Table View Per Component

While publishing views affects all tables globally, the view() method allows you to specify a custom view for a specific table component. This is useful when different tables need different layouts or styling.

View precedence (highest to lowest):

  1. view() method in component - Highest priority, overrides everything
  2. Config default_view - Global default for all tables
  3. Package default view - Used when neither above is set

Key differences:

  • Publishing views (php artisan vendor:publish --tag=slick-filters-views) - Modifies the package's default view globally
  • Config default_view - Sets a global default view path (see Configuration)
  • view() method - Affects only the specific component that overrides it
Basic Usage
<?php

namespace App\Livewire;

use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Itul\LivewireSlickFilters\SlickTable;

class UsersTable extends SlickTable
{
    protected function query(): Builder
    {
        return User::query();
    }

    /**
     * Use a custom view for this table only
     */
    protected function view(): string
    {
        return 'livewire.tables.users-table';
    }

    protected function columns(): array
    {
        return [
            self::addColumn('name'),
            self::addColumn('email'),
        ];
    }
}

Create the custom view at resources/views/livewire/tables/users-table.blade.php:

<div class="custom-users-table">
    <h2>Users Directory</h2>

    {{-- Custom header section --}}
    <div class="table-header">
        @if(config('slick-filters.enable_search', true))
            <input
                type="text"
                wire:model.live.debounce.300ms="search"
                placeholder="Search users..."
                class="search-input"
            />
        @endif
    </div>

    {{-- Use your custom table markup --}}
    <table class="users-table">
        <thead>
            <tr>
                @foreach($columns as $column)
                    <th>{{ $column->getLabel() }}</th>
                @endforeach
            </tr>
        </thead>
        <tbody>
            @foreach($rows as $row)
                <tr>
                    @foreach($columns as $column)
                        <td>{!! $column->renderValue($row) !!}</td>
                    @endforeach
                </tr>
            @endforeach
        </tbody>
    </table>

    {{-- Pagination --}}
    {{ $rows->links() }}
</div>
Multiple Components with Different Views

Each component can have its own custom view:

// UsersTable.php - Detailed view with avatars
class UsersTable extends SlickTable
{
    protected function view(): string
    {
        return 'livewire.tables.users-detailed';
    }
}

// OrdersTable.php - Compact view for dashboards
class OrdersTable extends SlickTable
{
    protected function view(): string
    {
        return 'livewire.tables.orders-compact';
    }
}

// ProductsTable.php - Grid view instead of table
class ProductsTable extends SlickTable
{
    protected function view(): string
    {
        return 'livewire.tables.products-grid';
    }
}

// ReportsTable.php - Uses default package view
class ReportsTable extends SlickTable
{
    // No view() method = uses default
}
View Resolution Order

When rendering a table, Livewire resolves views in this order:

  1. Component-specific view (if view() method is overridden)
  2. Published views (resources/views/vendor/slick-filters/components/table.blade.php)
  3. Package default views (vendor/itul/livewire-slick-filters/resources/views/components/table.blade.php)

Example resolution:

class CustomTable extends SlickTable
{
    protected function view(): string
    {
        return 'tables.custom'; // 1. Check resources/views/tables/custom.blade.php
    }
}

class StandardTable extends SlickTable
{
    // No view() override
    // 2. Check resources/views/vendor/slick-filters/components/table.blade.php
    // 3. Fall back to vendor/itul/livewire-slick-filters/resources/views/components/table.blade.php
}
Recommended View Structure

Organize your custom table views for clarity:

resources/views/
├── livewire/
│   └── tables/
│       ├── users-table.blade.php      # UsersTable component
│       ├── orders-table.blade.php     # OrdersTable component
│       └── products-grid.blade.php    # ProductsTable grid view
└── vendor/
    └── slick-filters/
        └── components/
            └── table.blade.php         # Global override (published)
Available Variables in Custom Views

Your custom view has access to these component properties:

{{-- Collection of Column objects --}}
@foreach($columns as $column)
    {{ $column->getLabel() }}
    {{ $column->getName() }}
    {{ $column->getField() }}
    @if($column->hasFilter())
        {{-- Render filter UI --}}
    @endif
@endforeach

{{-- Paginated query results --}}
@foreach($rows as $row)
    {{ $row->id }}
    {{ $row->name }}
@endforeach

{{-- Component properties --}}
{{ $search }}           // Current search query
{{ $filters }}          // Array of active filters
{{ $sortField }}        // Current sort field
{{ $sortDirection }}    // Current sort direction ('asc' or 'desc')
{{ $perPage }}          // Current per-page value

{{-- Component methods --}}
wire:click="sortBy('{{ $column->getField() }}')"
wire:click="clearFilters"
wire:click="clearFilter('{{ $column->getName() }}')"
Example: Card Layout Instead of Table
class TeamMembersTable extends SlickTable
{
    protected function view(): string
    {
        return 'livewire.tables.team-members-cards';
    }
}
{{-- resources/views/livewire/tables/team-members-cards.blade.php --}}
<div class="team-members-cards">
    {{-- Search bar --}}
    @if(config('slick-filters.enable_search', true))
        <input
            type="text"
            wire:model.live.debounce.300ms="search"
            placeholder="Search team members..."
            class="mb-4 w-full px-4 py-2 border rounded"
        />
    @endif

    {{-- Card grid instead of table --}}
    <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        @foreach($rows as $member)
            <div class="border rounded-lg p-4 shadow">
                <img src="{{ $member->avatar }}" class="w-16 h-16 rounded-full mb-2">
                <h3 class="font-bold">{{ $member->name }}</h3>
                <p class="text-gray-600">{{ $member->role }}</p>
                <p class="text-sm text-gray-500">{{ $member->email }}</p>
            </div>
        @endforeach
    </div>

    {{-- Pagination --}}
    <div class="mt-4">
        {{ $rows->links() }}
    </div>
</div>
Example: Extended Table with Extra Features
class AnalyticsTable extends SlickTable
{
    public bool $showCharts = true;

    protected function view(): string
    {
        return 'livewire.tables.analytics-extended';
    }
}
{{-- resources/views/livewire/tables/analytics-extended.blade.php --}}
<div class="analytics-table">
    {{-- Custom toolbar --}}
    <div class="toolbar flex justify-between items-center mb-4">
        <h2 class="text-xl font-bold">Analytics Dashboard</h2>

        <div class="flex gap-2">
            <button wire:click="exportCsv" class="btn-export">Export CSV</button>
            <button wire:click="$toggle('showCharts')" class="btn-toggle">
                {{ $showCharts ? 'Hide' : 'Show' }} Charts
            </button>
        </div>
    </div>

    {{-- Charts section --}}
    @if($showCharts)
        <div class="charts mb-6">
            {{-- Your chart components here --}}
        </div>
    @endif

    {{-- Include the default table view as a partial --}}
    @include('slick-filters::components.table')

    {{-- Custom footer with statistics --}}
    <div class="statistics mt-4 p-4 bg-gray-100 rounded">
        <p>Total Records: {{ $rows->total() }}</p>
        <p>Filtered: {{ $rows->count() }}</p>
    </div>
</div>
Using Package View as Base

You can extend the package's default view:

{{-- resources/views/livewire/tables/extended-table.blade.php --}}

{{-- Custom header before table --}}
<div class="custom-header mb-4">
    <h2>Custom Header</h2>
</div>

{{-- Include default package view --}}
@include('slick-filters::components.table')

{{-- Custom footer after table --}}
<div class="custom-footer mt-4">
    <p>Custom Footer Content</p>
</div>
When to Use Custom Views

Use the view() method when:

  • Specific table needs unique layout (cards, grid, timeline)
  • Adding custom toolbars, charts, or statistics to specific table
  • Different styling needed for different sections of your app (admin vs public)
  • Integrating table into existing design system with specific markup

Use published views when:

  • All tables should have the same appearance
  • Global styling changes needed across entire application

Use default package views when:

  • Default styling is acceptable
  • Only need to customize via CSS classes or rowClass()/cellClass()
Summary
ApproachScopeUse Case
view() methodSingle componentUnique layout for specific table
Published viewsGlobal (all tables)Consistent styling across app
Default viewsGlobal (all tables)No customization needed

The view() method gives you complete control over individual table layouts while keeping other tables using the default or published views!

Styling

The package includes basic CSS classes. You can:

  1. Override classes in the config file
  2. Publish and modify the views
  3. Add your own CSS targeting the default classes:
.slick-table {
    width: 100%;
    border-collapse: collapse;
}

.slick-table-header-cell {
    background: #f3f4f6;
    padding: 12px;
    text-align: left;
}

.slick-filter-input {
    width: 100%;
    padding: 8px;
    border: 1px solid #d1d5db;
    border-radius: 4px;
}

Configuration

The package provides a configuration file with global settings that apply to all table components unless overridden per component.

Publishing the Configuration

php artisan vendor:publish --tag=slick-filters-config

This creates config/slick-filters.php with all available options.

Configuration Options Reference

OptionTypeDefaultDescription
enable_searchboolfalseEnable/disable the global search bar across all columns with filters
per_page_optionsarray[10, 25, 50, 100]Available options in the per-page dropdown selector
default_per_pageint10Default number of records displayed per page
default_sort_directionstring'asc'Default sort direction when a sortable column is clicked ('asc' or 'desc')
debounce_delayint300Debounce delay in milliseconds for all filter inputs (prevents excessive queries)
themestring'default'Theme selection - currently unused/incomplete feature (for future use)
default_viewstring\|nullnullDefault Blade view path for all tables (can be overridden per component)
classesarraySee belowCSS class names for all table elements

Global Search Control

Enable or disable the global search bar for all tables:

// config/slick-filters.php
'enable_search' => false,  // Default: disabled

Per-component override:

class UsersTable extends SlickTable
{
    public function render()
    {
        config(['slick-filters.enable_search' => true]);
        return parent::render();
    }
}

Per-Page Options

Configure the options available in the per-page dropdown:

// config/slick-filters.php
'per_page_options' => [10, 25, 50, 100],
'default_per_page' => 10,

Per-component override:

class UsersTable extends SlickTable
{
    public int $perPage = 25; // Override default

    public function getPerPageOptions(): array
    {
        return [5, 10, 25, 50, 100, 250]; // Custom options
    }
}

Use cases:

  • Small tables: [5, 10, 25, 50]
  • Large datasets: [25, 50, 100, 250, 500]
  • Admin panels: [10, 25, 50, 100, 'All'] (note: 'All' requires custom handling)

Default Sort Direction

Sets the default direction when sorting columns:

// config/slick-filters.php
'default_sort_direction' => 'asc',  // Options: 'asc' or 'desc'

Per-component override:

class UsersTable extends SlickTable
{
    public string $sortField = 'created_at';
    public string $sortDirection = 'desc'; // Most recent first
}

Common patterns:

  • Lists: 'asc' (alphabetical A-Z)
  • Activity feeds: 'desc' (newest first)
  • Prices: 'asc' (lowest to highest)
  • Dates: 'desc' (most recent first)

Debounce Delay

Controls how long to wait (in milliseconds) after user stops typing before triggering a filter update:

// config/slick-filters.php
'debounce_delay' => 300,  // 300ms = 0.3 seconds

This setting applies to all filter inputs globally and prevents excessive database queries while users type.

How it works:

  • User types "john" in a text filter
  • After 300ms of no typing, the filter applies
  • Reduces database queries from 4 (j-o-h-n) to 1 (john)

Recommended values:

  • Fast typists: 200-300ms
  • Slow connections: 500-1000ms
  • Heavy queries: 500-1000ms
  • Real-time feel: 100-200ms

Cannot be overridden per component - this is a global setting that ensures consistent UX.

The debounce is implemented in all filter view templates via:

wire:model.live.debounce.{{ config('slick-filters.debounce_delay', 300) }}ms="filters.{{ $column->getName() }}"

Theme Option

// config/slick-filters.php
'theme' => 'default',  // Options: 'default', 'bootstrap', 'tailwind'

⚠️ Note: This option is currently incomplete/unused in the package. The views use inline styles for framework-agnostic compatibility.

For custom styling:

  1. Publish the views
  2. Modify resources/views/vendor/slick-filters/components/table.blade.php
  3. Replace inline styles with your framework classes

Future plans: This option may be used for pre-built theme templates in future versions.

Default Table View

Set a global default Blade view for all SlickTable components:

// config/slick-filters.php
'default_view' => 'livewire.tables.custom-table',

This is useful when you want all tables across your application to use a custom view without having to override the view() method in each component.

Per-component override:

You can still override the global default for specific components:

class UsersTable extends SlickTable
{
    protected function view(): string
    {
        return 'livewire.tables.users-specific-view';
    }
}

How it works:

  1. If default_view is set in config, all tables use that view by default
  2. If a component defines a view() method, it takes precedence over the config
  3. If neither is set, the package default view is used

Example use case:

You've created a custom table layout that all your admin tables should use:

// config/slick-filters.php
'default_view' => 'admin.layouts.data-table',

// Now all SlickTable components automatically use this view
// unless they explicitly override it

Set to null (default) to use the package's built-in view.

Custom CSS Classes

Override the default CSS class names for all table elements:

// config/slick-filters.php
'classes' => [
    'container' => 'slick-table-container',
    'table' => 'slick-table',
    'header' => 'slick-table-header',
    'row' => 'slick-table-row',
    'cell' => 'slick-table-cell',
    'filter-input' => 'slick-filter-input',
    'search-input' => 'slick-search-input',
],

Use cases:

Bootstrap 5:

'classes' => [
    'container' => 'table-responsive',
    'table' => 'table table-striped table-hover',
    'header' => 'thead-dark',
    'row' => '',
    'cell' => '',
    'filter-input' => 'form-control form-control-sm',
    'search-input' => 'form-control',
],

Tailwind CSS:

'classes' => [
    'container' => 'overflow-x-auto',
    'table' => 'min-w-full divide-y divide-gray-200',
    'header' => 'bg-gray-50',
    'row' => 'hover:bg-gray-50',
    'cell' => 'px-6 py-4 whitespace-nowrap text-sm',
    'filter-input' => 'px-3 py-2 border border-gray-300 rounded-md focus:ring-indigo-500',
    'search-input' => 'px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500',
],

Note: Custom classes defined here apply globally. For per-row or per-cell styling, use rowClass() and cellClass() methods instead.

Complete Configuration Example

<?php

return [
    // Enable global search across all filterable columns
    'enable_search' => false,

    // Per-page dropdown options
    'per_page_options' => [10, 25, 50, 100],
    'default_per_page' => 25,

    // Default sort direction for sortable columns
    'default_sort_direction' => 'asc',

    // Debounce delay for filter inputs (milliseconds)
    'debounce_delay' => 400,

    // Theme (currently unused - for future use)
    'theme' => 'default',

    // Global CSS class overrides
    'classes' => [
        'container' => 'table-responsive',
        'table' => 'table table-striped',
        'header' => 'thead-light',
        'row' => '',
        'cell' => '',
        'filter-input' => 'form-control form-control-sm',
        'search-input' => 'form-control',
    ],
];

Summary: Global vs Per-Component Settings

SettingGlobal ConfigPer-Component OverrideNotes
Searchenable_searchconfig(['slick-filters.enable_search' => false]) in render()Can be disabled per component
Per-page optionsper_page_optionsgetPerPageOptions() methodArray of available options
Default per-pagedefault_per_pagepublic int $perPage = 25;Initial page size
Sort directiondefault_sort_directionpublic string $sortDirection = 'desc';Per-table default
Debouncedebounce_delay❌ Cannot overrideGlobal only
Themetheme❌ Unused featurePublish views instead
Default viewdefault_viewview() methodGlobal default view path
CSS classesclassesPublish viewsGlobal defaults

Example: Complete Implementation

<?php

namespace App\Livewire;

use App\Models\Product;
use Illuminate\Database\Eloquent\Builder;
use Itul\LivewireSlickFilters\SlickTable;

class ProductsTable extends SlickTable
{
    public string $sortField = 'name';
    public string $sortDirection = 'asc';
    public int $perPage = 25;

    protected function columns(): array
    {
        return [
            self::addColumn('id') // Auto: label='Id', sortable, text filter, global search
                ->filter('numeric'), // Override to numeric, still in global search

            self::addColumn('name', 'Product Name'), // Custom label, text filter, global search

            self::addColumn('sku') // Auto: label='Sku', sortable, text filter (exact), global search
                ->filter('text', 'exact'), // Override default with exact match

            self::addColumn('category') // Auto: label='Category', sortable, text filter, global search
                ->filter('select', [ // Override to select, still in global search
                    'electronics' => 'Electronics',
                    'clothing' => 'Clothing',
                    'home' => 'Home & Garden',
                ]),

            self::addColumn('price') // Auto: label='Price', sortable, text filter, global search
                ->filter('numeric-range', [ // Override to numeric-range, still in global search
                    'placeholder_min' => 'Min',
                    'placeholder_max' => 'Max',
                    'step' => 0.01,
                ])
                ->display(fn($record) => '$' . number_format($record->price, 2)),

            self::addColumn('stock') // Auto: label='Stock', sortable, text filter, global search
                ->filter('numeric', '>=') // Override to numeric, still in global search
                ->display(fn($record) => number_format($record->stock)),

            self::addColumn('created_at', 'Created') // Custom label, global search
                ->filter('date-range') // Date range filter, still in global search
                ->display(fn($record) => $record->created_at?->format('M d, Y')),
        ];
    }

    protected function query(): Builder
    {
        return Product::query()
            ->where('active', true);
    }
}

Reference: Available Operators

Text Operators

*Case-insensitive (like-)**

  • like-contains (default) - Contains text
  • like - Exact match
  • like-starts-with - Starts with text
  • like-ends-with - Ends with text
  • not-like-contains - Does NOT contain text
  • not-like - Does NOT match exactly
  • not-like-starts-with - Does NOT start with text
  • not-like-ends-with - Does NOT end with text

Case-sensitive

  • contains - Contains text
  • exact - Exact match
  • starts-with - Starts with text
  • ends-with - Ends with text
  • not-contains - Does NOT contain text
  • not-exact - Does NOT match exactly
  • not-starts-with - Does NOT start with text
  • not-ends-with - Does NOT end with text

Numeric & Date Operators

  • = (default) - Equal to
  • != or not-= - Not equal to
  • > - Greater than
  • >= - Greater than or equal
  • < - Less than
  • <= - Less than or equal

Select Operators

  • = (default) - Equals
  • != or not-= - Not equal to
  • in - In array (for multiple select)
  • not-in or not_in - Not in array

API Reference

Quick lookup reference for experienced developers. For detailed examples, see the linked sections.

SlickTable Component

Public Properties

public string $search = '';           // Global search value
public array $filters = [];           // Active filters ['field' => 'value']
public string $sortField = '';        // Current sort field
public string $sortDirection = 'asc'; // Current sort direction
public int $perPage = 10;             // Records per page

Protected Methods to Override

MethodReturnsPurposeDetails
query()BuilderDefine base queryCustom Queries
columns()arrayDefine table columnsCreating Columns
view()stringCustom view pathOverride Table View
rowClass($record)stringDynamic row CSS classesrowClass()
mergeColumns($custom, $ignore = [])arrayMerge auto-generated + custom columnsPartial Customization
generateColumnsFromFillable()arrayAuto-generate from $fillableColumn Precedence
generateColumnsFromSchema()arrayAuto-generate from DB schemaSchema Generation
inferFilterFromCast($field, $cast)?FilterInterfaceMap model casts to filtersFilter Inference
inferFilterFromColumnType(...)?FilterInterfaceMap DB types to filtersFilter Inference
createEnumFilter($table, $field, $conn)FilterInterfaceCreate enum select filterFilter Inference
getData()LengthAwarePaginatorOverride query buildingQuery Customization
getEnhancedColumns()arrayCustomize column enhancementQuery Customization
getPerPageOptions()arrayPer-page dropdown optionsPer Page Options

Lifecycle Hooks

HookTriggered WhenDetails
updatedFilters()Any filter changesLifecycle Hooks
updatedSearch()Global search changesLifecycle Hooks
updatedPerPage()Per-page value changesLifecycle Hooks

Public Methods

MethodParametersPurposeDetails
clearFilters()-Clear all filtersPublic Methods
clearFilter($field)string $fieldClear specific filterPublic Methods
sortBy($field)string $fieldToggle sort on fieldAuto-wired to column headers

Helper Methods

MethodReturnsPurpose
getColumns()arrayGet all columns (respects precedence)
addColumn($field, $label = null)ColumnCreate new column (static)
addActions($actions, $label = 'Actions')ColumnCreate actions column (static)

Column Class

Creation Methods

// Static factory methods
Column::make(string $field, ?string $label = null): Column
Column::addColumn(string $field, ?string $label = null): Column  // Alias

Configuration Methods

MethodParametersReturnsPurposeDetails
field($field)stringColumnSet database field nameBasic Methods
sortable($sortable)bool = trueColumnEnable/disable sortingBasic Methods
filter($type, $options...)mixedColumnSet filter type and optionsOverriding Inferred Filters
display($callback)callableColumnCustom value renderingdisplay()
cellClass($classes)string\|callableColumnCell CSS classescellClass()

Filter Methods

// Simplified filter syntax
->filter('text', 'like-contains')
->filter('numeric', '>=')
->filter('date', '=')
->filter('date-range')
->filter('numeric-range')
->filter('select', ['value' => 'Label'])

// Using filter classes
->filter(TextFilter::make('exact'))
->filter(NumericFilter::make('>='))

// Disable filter
->filter(false)

Query Methods

MethodReturnsPurposeDetails
hasFilter()boolCheck if column has filterHelper Methods
hasDisplay()boolCheck if has custom displayHelper Methods
renderValue($record)mixedRender column valueHelper Methods
getVisibleActions($record)arrayGet visible actions for recordHelper Methods

Getter Methods

MethodReturnsPurpose
getLabel()stringGet column label
getName()stringGet column name (normalized)
getField()stringGet database field name
getFilter()?FilterInterfaceGet filter instance
getCellClass($record)stringGet cell CSS classes
isSortable()boolCheck if sortable
isActionsColumn()boolCheck if actions column

Internal Methods

MethodPurpose
setInferredFilter(FilterInterface $filter)Set auto-inferred filter
needsFilterInference()Check if needs auto-inference

Filter Classes

All filter classes implement FilterInterface:

interface FilterInterface
{
    public function apply(Builder $query, string $field, mixed $value): Builder;
    public function getType(): string;
    public function getOptions(): array;
}

TextFilter

TextFilter::make(string $operator = 'like-contains', array $options = []): self

// Operators
'like-contains', 'like', 'like-starts-with', 'like-ends-with',
'not-like-contains', 'not-like', 'not-like-starts-with', 'not-like-ends-with',
'contains', 'exact', 'starts-with', 'ends-with',
'not-contains', 'not-exact', 'not-starts-with', 'not-ends-with'

// Options
['placeholder' => 'Search...']

Details

NumericFilter

NumericFilter::make(string $operator = '=', array $options = []): self

// Operators
'=', '!=', 'not-=', '>', '>=', '<', '<='

// Options
['placeholder' => 'Enter number...', 'min' => null, 'max' => null, 'step' => 'any']

Details

DateFilter

DateFilter::make(string $operator = '=', array $options = []): self

// Operators
'=', '!=', 'not-=', '>', '>=', '<', '<='

// Options
['placeholder' => 'Select date...', 'format' => 'Y-m-d']

Details

DateRangeFilter

DateRangeFilter::make(array $options = []): self

// Value format
['start' => '2024-01-01', 'end' => '2024-12-31']
// or
['2024-01-01', '2024-12-31']

// Options
['placeholder_start' => 'From...', 'placeholder_end' => 'To...', 'format' => 'Y-m-d']

Details

NumericRangeFilter

NumericRangeFilter::make(array $options = []): self

// Value format
['min' => 10, 'max' => 100]
// or
[10, 100]

// Options
['placeholder_min' => 'Min', 'placeholder_max' => 'Max', 'step' => 'any']

Details

SelectFilter

SelectFilter::make(array $options = [], string $operator = '=', array $config = []): self

// Options (dropdown values)
['active' => 'Active', 'inactive' => 'Inactive']

// Operators
'=', '!=', 'not-=', 'in', 'not-in', 'not_in'

// Config
['placeholder' => 'Select...', 'multiple' => false]

Details

Creating Custom Filters

class CustomFilter implements FilterInterface
{
    public function apply(Builder $query, string $field, mixed $value): Builder;
    public function getType(): string; // Return unique type identifier
    public function getOptions(): array; // Return config for view
    public function withLabel(string $label): self; // Optional for smart placeholders
}

Details

Configuration Options

Global settings in config/slick-filters.php:

OptionTypeDefaultOverridableDetails
enable_searchboolfalseYes (per component)Configuration
per_page_optionsarray[10, 25, 50, 100]Yes (getPerPageOptions())Configuration
default_per_pageint10Yes (public int $perPage)Configuration
default_sort_directionstring'asc'Yes (public string $sortDirection)Configuration
debounce_delayint300❌ No (global only)Configuration
themestring'default'❌ Unused featureConfiguration
classesarraySee configPublish viewsConfiguration

Common Patterns

Zero-Config Table

class UsersTable extends SlickTable
{
    protected function query(): Builder
    {
        return User::query();
    }
    // That's it! Auto-generates everything
}

Partial Customization

protected function columns(): array
{
    return $this->mergeColumns(
        [
            self::addColumn('role')->filter('select', [...]),
        ],
        ['password', 'api_token'] // Optional: exclude these from auto-generation
    );
}

Custom Query with Relationships

protected function query(): Builder
{
    return Order::query()
        ->with(['customer', 'items'])
        ->leftJoin('users', 'orders.customer_id', '=', 'users.id')
        ->select('orders.*', 'users.name as customer_name');
}

Lifecycle Hook Validation

protected function updatedFilters()
{
    if (isset($this->filters['price']['min'], $this->filters['price']['max'])) {
        if ($this->filters['price']['min'] > $this->filters['price']['max']) {
            $this->filters['price']['min'] = $this->filters['price']['max'];
        }
    }
}

Multiple Tables on One Page

<livewire:users-table key="users" />
<livewire:orders-table key="orders" />

Full Feature List

⚡ Instant Productivity

  • 🚀 Zero-code deployment - Use <livewire:slick-table model="App\Models\User" /> directly in Blade - no PHP required!
  • 🧠 Intelligent automation - Auto-generates columns, labels, and filters from your model's $casts and DB schema
  • 🎯 Three-tier column generation - Explicit columns → $fillable property → database schema (automatic fallback)
  • 🔌 Works with any Eloquent model - Drop-in ready, no configuration needed
  • ⚙️ Partial customization - Override only what you need with mergeColumns() - keep the rest automatic

🎨 Rich Interactivity

  • 🎬 Built-in action dropdowns - Row actions with conditional visibility, confirmations, icons, and multiple menus
  • ✨ Advanced cell customization - HTML callbacks to render badges, icons, combined fields, formatted values
  • 🌈 Conditional styling - Dynamic row classes (rowClass()) and cell classes (cellClass()) based on data
  • 🏷️ Active filter badges - Visual UI showing active filters with individual × buttons and "Clear All"
  • 🗂️ Relationship support - Display and filter related model data with ->field('user.name')
  • 🪝 Lifecycle hooks - updatedFilters(), updatedSearch(), updatedPerPage() for custom logic

🎛️ Powerful Filter System

  • 📊 Six filter types - Text, Numeric, Date, Date-Range, Numeric-Range, Select
  • 🔢 15+ operators - Comparison (=, !=, >, >=, <, <=), LIKE variations, negation (NOT)
  • 🔍 Smart global search - Automatically searches all filterable columns with debouncing
  • ❌ Negation support - NOT operators for excluding results (not-contains, not-equals, not-in)
  • 🔤 Case-sensitive options - Choose case-sensitive or case-insensitive text matching
  • 🎭 Enum auto-detection - Automatically extracts database enum values for select filters
  • 📝 Smart placeholders - Context-aware placeholder text based on column labels
  • 🤖 Automatic filter inference - Respects $casts, inspects DB schema, infers appropriate filter types
  • 🎨 Custom filter creation - Implement FilterInterface to create specialized filter types

🛠️ Developer Experience

  • 🔗 Shareable URLs - All filters, sorting, and pagination persist in query strings for bookmarkable states
  • ↕️ Column sorting - Click-to-sort with visual indicators and customizable default direction
  • 📄 Flexible pagination - Configurable per-page options (10, 25, 50, 100+) with user control
  • 🎨 Fully customizable views - Override table layout, filters, or individual components per table
  • 🔄 Multiple tables per page - Isolated state with Livewire key support
  • 📋 Query building customization - Override getData(), getEnhancedColumns(), and query() methods
  • 🎛️ Filter inference customization - Override inferFilterFromCast(), inferFilterFromColumnType(), createEnumFilter()
  • 📐 Schema generation customization - Override generateColumnsFromSchema() to exclude fields or customize labels
  • 🔧 Configuration options - Global settings for search, pagination, debounce, CSS classes, and more
  • ⚡ Debounced inputs - Configurable debounce delay prevents excessive queries during typing

Troubleshooting

Common issues, edge cases, and their solutions.

Case-Sensitive Filters Not Working (PostgreSQL/SQLite)

Problem: Case-sensitive text filter operators (contains, exact, starts-with, ends-with) don't work as expected on PostgreSQL or SQLite.

Cause: These operators use MySQL's BINARY comparison which is database-specific:

// TextFilter for case-sensitive operators
'exact' => $query->where($field, '=', $value), // Works everywhere
'contains' => $query->whereRaw("BINARY {$field} LIKE ?", ["%{$value}%"]), // MySQL only

Solution for PostgreSQL:

Use collation for case-sensitive matching:

<?php

namespace App\Livewire;

use App\Models\Product;
use Illuminate\Database\Eloquent\Builder;
use Itul\LivewireSlickFilters\SlickTable;

class ProductsTable extends SlickTable
{
    protected function query(): Builder
    {
        // Apply case-sensitive filter manually
        $query = Product::query();

        if (!empty($this->filters['sku'])) {
            // PostgreSQL: Use COLLATE for case-sensitive
            $query->whereRaw(
                "sku COLLATE \"C\" LIKE ?",
                ['%' . $this->filters['sku'] . '%']
            );
        }

        return $query;
    }

    protected function columns(): array
    {
        return [
            self::addColumn('sku')
                ->filter(false), // Disable auto filter, use manual above
            self::addColumn('name'),
        ];
    }
}

Solution for SQLite:

SQLite doesn't have built-in case-sensitive collation. Use GLOB or binary comparison:

if (!empty($this->filters['sku'])) {
    // SQLite: Use GLOB for case-sensitive
    $query->whereRaw("sku GLOB ?", ['*' . $this->filters['sku'] . '*']);
}

Recommendation: Use case-insensitive like-* operators for database portability.

Date Filters Not Matching DateTime Columns

Problem: DateFilter returns no results even though datetime data exists.

Cause: DateFilter uses whereDate() which strips the time component:

// DateFilter.php
'=' => $query->whereDate($field, '=', $value)

For a datetime column 2024-01-15 14:30:00:

  • Searching 2024-01-15 ✅ Works (time stripped)
  • Searching 2024-01-15 14:30:00 ❌ Fails (exact match after time strip)

Solution 1: Use DateRangeFilter for datetime columns

self::addColumn('created_at')
    ->filter('date-range') // Better for datetime columns

Solution 2: Override filter inference for datetime columns

protected function inferFilterFromColumnType(
    string $table,
    string $field,
    string $columnType,
    $connection
) {
    // Use DateRangeFilter for all datetime columns
    if ($columnType === 'datetime' || $columnType === 'timestamp') {
        return DateRangeFilter::make();
    }

    return parent::inferFilterFromColumnType($table, $field, $columnType, $connection);
}

Solution 3: Search by date range in query

protected function query(): Builder
{
    $query = Order::query();

    if (!empty($this->filters['created_at'])) {
        $date = $this->filters['created_at'];
        $query->whereBetween('created_at', [
            $date . ' 00:00:00',
            $date . ' 23:59:59',
        ]);
    }

    return $query;
}

Relationship Fields Not Auto-Filtering

Problem: Columns with dot notation (e.g., user.name) don't get automatic filters.

Cause: Filter inference only works on direct model columns, not relationships:

self::addColumn('author', 'Author')
    ->field('user.name') // Dot notation detected
    // Filter inference skipped (no user.name column in table)

Solution: Explicitly define filters for relationship fields

protected function columns(): array
{
    return [
        self::addColumn('author_name', 'Author')
            ->field('user.name')
            ->filter('text', 'like-contains') // Explicit filter required
            ->sortable(false), // Sorting relationships requires joins
    ];
}

For filtering: Ensure relationship is loaded in query

protected function query(): Builder
{
    return Article::query()->with('user');
}

For sorting: Add join in query

protected function query(): Builder
{
    return Article::query()
        ->leftJoin('users', 'articles.user_id', '=', 'users.id')
        ->select('articles.*');
}

Numeric Filters Showing Unexpected Results

Problem: Numeric filter returns results that don't match the input exactly.

Cause: NumericFilter automatically casts values to float:

// NumericFilter.php
$value = (float) $value; // "100" becomes 100.0

Why: Ensures consistent comparison for decimal values:

$this->filters['price'] = '99.99';
// Becomes: WHERE price = 99.99 (float)
// Not: WHERE price = '99.99' (string comparison)

Solution: This is expected behavior. For exact string matching (e.g., SKU codes), use TextFilter:

self::addColumn('sku', 'SKU')
    ->filter('text', 'exact') // String comparison

Column Names Not Matching Database

Problem: Column creation fails or filters don't work with camelCase field names.

Cause: Column names are automatically normalized to snake_case:

// Column.php
self::addColumn('emailAddress') // Input: camelCase
// Becomes: field='email_address', label='Email Address'

Solution 1: Use ->field() to specify exact database column

self::addColumn('email_address', 'Email')
    ->field('EmailAddress') // Exact database column name

Solution 2: Use snake_case in column definitions

// Recommended: Match database conventions
self::addColumn('email_address') // Clear and matches DB

Empty Filters Still Applying

Problem: Filter seems to apply even when input is cleared.

Cause: The value "0" is considered valid and applies the filter:

// All filters check:
if (empty($value)) {
    return $query; // Skip filter
}

// But "0" is NOT empty!
$this->filters['quantity'] = "0"; // Filter WILL apply

Solution: This is intentional for searching zero values. To clear:

// Clear programmatically
$this->clearFilter('quantity');

// Or in Blade
<button wire:click="clearFilter('quantity')">Clear</button>

Filters Not Persisting in URL

Problem: Filter values disappear on page refresh.

Cause: $queryString property not configured or overridden incorrectly.

Solution: Ensure $queryString includes filters

// Default behavior (automatic)
protected $queryString = [
    'filters' => ['except' => []],
    'search' => ['except' => ''],
    // ...
];

// If you override, include filters!
protected $queryString = [
    'filters', // Simple form
    'search',
];

Performance Issues with Large Tables

Problem: Table loads slowly or times out with many records.

Common causes:

  1. No database indexes on filtered columns
  2. Global search across too many columns
  3. N+1 query problems with relationships
  4. Large per-page values

Solutions:

1. Add database indexes:

// Migration
Schema::table('orders', function (Blueprint $table) {
    $table->index('status'); // Frequently filtered
    $table->index('created_at'); // Date range filters
    $table->index(['customer_id', 'status']); // Composite index
});

2. Limit searchable columns:

self::addColumn('internal_notes')
    ->filter(false) // Exclude from global search

3. Eager load relationships:

protected function query(): Builder
{
    return Order::query()
        ->with(['customer', 'items']) // Prevent N+1
        ->select('orders.*');
}

4. Reduce default per-page:

public int $perPage = 25; // Instead of 100

5. Use pagination efficiently:

// Avoid
$rows = $query->paginate($this->perPage); // Counts all records

// Better for large tables
$rows = $query->simplePaginate($this->perPage); // No total count

Filter Values Not Validating

Problem: Invalid filter values cause database errors.

Solution: Use lifecycle hooks for validation

protected function updatedFilters()
{
    // Validate price range
    if (isset($this->filters['price']['min'], $this->filters['price']['max'])) {
        if ($this->filters['price']['min'] > $this->filters['price']['max']) {
            $this->filters['price']['min'] = $this->filters['price']['max'];
            session()->flash('warning', 'Min price cannot exceed max price');
        }
    }

    // Validate date format
    if (!empty($this->filters['created_at'])) {
        if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $this->filters['created_at'])) {
            unset($this->filters['created_at']);
            session()->flash('error', 'Invalid date format');
        }
    }
}

Custom View Not Rendering

Problem: Override view() method but table still uses default view.

Cause: Typo in view path or view file doesn't exist.

Solution: Check view resolution

// Component
protected function view(): string
{
    return 'livewire.tables.custom'; // Check this path
}

// File must exist at:
// resources/views/livewire/tables/custom.blade.php

// Debug by temporarily adding:
protected function view(): string
{
    $viewPath = 'livewire.tables.custom';
    if (!view()->exists($viewPath)) {
        throw new \Exception("View not found: {$viewPath}");
    }
    return $viewPath;
}

Sorting Not Working on Relationship Columns

Problem: Clicking sort on relationship column causes error or no sorting.

Cause: Can't sort on relationship fields without joins.

Solution: Disable sorting or add join

// Option 1: Disable sorting
self::addColumn('author', 'Author')
    ->field('user.name')
    ->sortable(false) // Can't sort relationships easily

// Option 2: Add join and use database column
protected function query(): Builder
{
    return Article::query()
        ->leftJoin('users', 'articles.user_id', '=', 'users.id')
        ->select('articles.*', 'users.name as author_name');
}

protected function columns(): array
{
    return [
        self::addColumn('author_name', 'Author'), // Now sortable!
    ];
}

Session/Pagination Conflicts Between Tables

Problem: Multiple tables on same page interfere with each other's filters.

Cause: Livewire components share default ID space.

Solution: Use unique key attributes

{{-- Multiple tables on same page --}}
<livewire:users-table key="users-table" />
<livewire:orders-table key="orders-table" />
<livewire:products-table key="products-table" />

Common Mistakes Summary

IssueCauseSolution
Case-sensitive not workingBINARY MySQL-onlyUse collation or like-* operators
DateTime not filteringwhereDate() strips timeUse DateRangeFilter
Relationships not filteringNo auto-inference for dot notationExplicit filter + joins
"0" treated as emptyIntentional (valid value)Use clearFilter()
Snake_case mismatchAuto-normalizationUse ->field() for exact name
Slow performanceMissing indexesAdd DB indexes, limit columns
Multi-table conflictsShared component IDsUse unique key attributes

License

MIT License

Credits

Created by Brandon Moore brandon@i-tul.com