itul / livewire-slick-filters
A Laravel Livewire package for filtering, sorting, and paginating table data with live updates
Requires
- php: ^8.1|^8.2|^8.3|^8.4
- illuminate/support: ^10.0|^11.0|^12.0
- livewire/livewire: ^3.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpunit/phpunit: ^10.0|^11.0
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
Table of Contents
- Installation
- Quick Start
- Columns
- Filters
- Advanced Usage
- Public Methods
- Lifecycle Hooks
- Creating Custom Filters
- Customizing Filter Inference
- Partial Customization with mergeColumns
- Customizing Schema-Based Column Generation
- Custom Queries with Relationships
- Advanced Query Customization
- Query String Customization
- Default Sorting
- Default Per Page
- Customize Per Page Options
- Disable Search
- Filter Behavior Reference
- Customization
- Example: Complete Implementation
- Reference: Available Operators
- API Reference
- Full Feature List
- License
- Credits
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:
- Implement filter logic in the
apply()
method - Customize the Blade view with your filter UI
- Add any configuration options to
getOptions()
- 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:
- 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
- Empty array
- Model's
$fillable
property - Ifcolumns()
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
- Automatically excludes ID columns - Skips
- 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
- Automatically excludes ID columns - Skips
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 menuaction
(required) - Livewire method name to callicon
(optional) - HTML/SVG icon to display before the labelclass
(optional) - CSS classes for styling (e.g., 'text-danger')confirm
(optional) - Confirmation message before executing actionvisible
(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
Eager load relationships to avoid N+1 queries:
protected function query() { return Post::with(['user', 'category']); }
Use joins for sortable relationship columns - ensures efficient querying
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)
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
- Explicit
->filter()
call (Highest Priority) - Your explicit choice - Model's
$casts
attribute - Respects your model's type casting - Database schema inspection - Infers from actual column type
- Default text filter (Fallback) - If all else fails
Model Cast Types → Filters
Model Cast | Inferred Filter | Example |
---|---|---|
boolean , bool | Select (Yes/No) | 'is_active' => 'boolean' |
integer , int | Numeric | 'age' => 'integer' |
decimal , float , double | Numeric | 'price' => 'decimal:2' |
date | Date | 'birth_date' => 'date' |
datetime , timestamp | Date | 'published_at' => 'datetime' |
immutable_date , immutable_datetime | Date | Laravel 8+ immutable dates |
array , json , collection , object | No filter | Arrays/JSON aren't filterable |
encrypted | No filter | Encrypted data can't be filtered |
string | Text | 'name' => 'string' |
Database Schema → Filters
If no cast is defined, falls back to database schema:
Database Type | Inferred Filter |
---|---|
int , bigint , smallint , tinyint | Numeric filter |
decimal , float , double | Numeric filter |
date | Date filter |
datetime , timestamp | Date filter |
boolean | Select filter (Yes/No) |
enum | Select filter with enum values |
varchar , text , char | Text 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:
- Implement filter logic in the
apply()
method - Customize the Blade view UI
- 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:
- See complete examples: examples/CUSTOM_FILTERS_README.md
- Working ColorPickerFilter: examples/Filters/ColorPickerFilter.php
- Working SliderFilter: examples/Filters/SliderFilter.php
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
Approach | Code Required | Customization | Best For |
---|---|---|---|
Generic Blade Component | None (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 control | Full | Complex 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:
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
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'
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:
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
- Always validate input - Check for empty values in
apply()
method - Return the query builder - Always return
$query
fromapply()
- Provide defaults - Use array_merge for default options in
getOptions()
- Implement withLabel() - Enable smart placeholder integration
- Document your filter - Add PHPDoc comments explaining parameters
- Make it chainable - Return
$this
from configuration methods - Reuse views when possible - Use built-in filter types if UI is similar
Summary
Creating custom filters involves:
- Implement FilterInterface - Three required methods:
apply()
,getType()
,getOptions()
- Add withLabel() - Optional but recommended for smart placeholders
- Create Blade component - UI for your filter (or reuse existing type)
- Register in table view - Add your filter type to the view logic
- 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 calls → model $casts → database schema → default 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:
inferFilterFromCast(string $field, string $castType)
- Maps model cast types to filtersinferFilterFromColumnType(...)
- Maps database column types to filterscreateEnumFilter(...)
- 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
- Always call parent method - Fall back to default behavior for standard types
- Handle exceptions gracefully - Use try-catch in enum methods
- Check field naming patterns - Leverage conventions (
_at
for dates,_by
for users, etc.) - Cache expensive operations - Store user lists, enum values if called frequently
- Document custom mappings - Add PHPDoc comments explaining inference logic
- Test edge cases - Verify behavior when columns don't exist or casts change
Summary
Filter inference customization involves three protected methods:
Method | Purpose | When to Override |
---|---|---|
inferFilterFromCast() | Map model casts to filters | Custom cast types, third-party packages |
inferFilterFromColumnType() | Map DB column types to filters | Database-specific types, field naming conventions |
createEnumFilter() | Generate select filters from enums | Custom 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:
mergeColumns()
automatically callsgenerateColumnsFromFillable()
orgenerateColumnsFromSchema()
internally- Your custom columns override any auto-generated columns with the same name
- Any columns in the optional
$ignore
parameter are excluded from auto-generation - All other columns are auto-generated with default settings
- This gives you the best of both worlds: automation + customization where you need it
Benefits:
- ✅ No need to manually call
generateColumnsFromFillable()
orgenerateColumnsFromSchema()
- ✅ 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
andremember_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
- Always exclude sensitive fields - password, tokens, API keys, secrets
- Use array_diff for exclusions - Clean and readable approach
- Handle exceptions gracefully - Return empty array on failure
- Document custom logic - Add PHPDoc explaining your customizations
- Consider using $fillable instead - Define $fillable on model when possible (more maintainable)
- Test schema changes - Verify exclusions work when columns added/removed
When to Override generateColumnsFromSchema()
Scenario | Should Override? | Alternative Approach |
---|---|---|
Exclude additional sensitive fields | ✅ Yes | Define $fillable on model |
Legacy database with cryptic names | ✅ Yes | Create database views with better names |
Multi-tenant column filtering | ✅ Yes | Define $fillable excluding tenant columns |
Customize all column labels/filters | ❌ No | Use explicit columns() method instead |
Simple table with good column names | ❌ No | Let 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
andremember_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:
Database | BINARY Support | Case-Sensitive Alternative |
---|---|---|
MySQL | ✅ Yes | Native BINARY keyword |
MariaDB | ✅ Yes | Native BINARY keyword |
PostgreSQL | ❌ No | Use COLLATE "C" or custom filter |
SQLite | ❌ No | Use COLLATE BINARY or custom filter |
SQL Server | ❌ No | Use 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):
view()
method in component - Highest priority, overrides everything- Config
default_view
- Global default for all tables - 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:
- Component-specific view (if
view()
method is overridden) - Published views (
resources/views/vendor/slick-filters/components/table.blade.php
) - 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
Approach | Scope | Use Case |
---|---|---|
view() method | Single component | Unique layout for specific table |
Published views | Global (all tables) | Consistent styling across app |
Default views | Global (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:
- Override classes in the config file
- Publish and modify the views
- 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
Option | Type | Default | Description |
---|---|---|---|
enable_search | bool | false | Enable/disable the global search bar across all columns with filters |
per_page_options | array | [10, 25, 50, 100] | Available options in the per-page dropdown selector |
default_per_page | int | 10 | Default number of records displayed per page |
default_sort_direction | string | 'asc' | Default sort direction when a sortable column is clicked ('asc' or 'desc' ) |
debounce_delay | int | 300 | Debounce delay in milliseconds for all filter inputs (prevents excessive queries) |
theme | string | 'default' | Theme selection - currently unused/incomplete feature (for future use) |
default_view | string\|null | null | Default Blade view path for all tables (can be overridden per component) |
classes | array | See below | CSS 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:
- Publish the views
- Modify
resources/views/vendor/slick-filters/components/table.blade.php
- 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:
- If
default_view
is set in config, all tables use that view by default - If a component defines a
view()
method, it takes precedence over the config - 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
Setting | Global Config | Per-Component Override | Notes |
---|---|---|---|
Search | enable_search | config(['slick-filters.enable_search' => false]) in render() | Can be disabled per component |
Per-page options | per_page_options | getPerPageOptions() method | Array of available options |
Default per-page | default_per_page | public int $perPage = 25; | Initial page size |
Sort direction | default_sort_direction | public string $sortDirection = 'desc'; | Per-table default |
Debounce | debounce_delay | ❌ Cannot override | Global only |
Theme | theme | ❌ Unused feature | Publish views instead |
Default view | default_view | view() method | Global default view path |
CSS classes | classes | Publish views | Global 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 textlike
- Exact matchlike-starts-with
- Starts with textlike-ends-with
- Ends with textnot-like-contains
- Does NOT contain textnot-like
- Does NOT match exactlynot-like-starts-with
- Does NOT start with textnot-like-ends-with
- Does NOT end with text
Case-sensitive
contains
- Contains textexact
- Exact matchstarts-with
- Starts with textends-with
- Ends with textnot-contains
- Does NOT contain textnot-exact
- Does NOT match exactlynot-starts-with
- Does NOT start with textnot-ends-with
- Does NOT end with text
Numeric & Date Operators
=
(default) - Equal to!=
ornot-=
- Not equal to>
- Greater than>=
- Greater than or equal<
- Less than<=
- Less than or equal
Select Operators
=
(default) - Equals!=
ornot-=
- Not equal toin
- In array (for multiple select)not-in
ornot_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
Method | Returns | Purpose | Details |
---|---|---|---|
query() | Builder | Define base query | Custom Queries |
columns() | array | Define table columns | Creating Columns |
view() | string | Custom view path | Override Table View |
rowClass($record) | string | Dynamic row CSS classes | rowClass() |
mergeColumns($custom, $ignore = []) | array | Merge auto-generated + custom columns | Partial Customization |
generateColumnsFromFillable() | array | Auto-generate from $fillable | Column Precedence |
generateColumnsFromSchema() | array | Auto-generate from DB schema | Schema Generation |
inferFilterFromCast($field, $cast) | ?FilterInterface | Map model casts to filters | Filter Inference |
inferFilterFromColumnType(...) | ?FilterInterface | Map DB types to filters | Filter Inference |
createEnumFilter($table, $field, $conn) | FilterInterface | Create enum select filter | Filter Inference |
getData() | LengthAwarePaginator | Override query building | Query Customization |
getEnhancedColumns() | array | Customize column enhancement | Query Customization |
getPerPageOptions() | array | Per-page dropdown options | Per Page Options |
Lifecycle Hooks
Hook | Triggered When | Details |
---|---|---|
updatedFilters() | Any filter changes | Lifecycle Hooks |
updatedSearch() | Global search changes | Lifecycle Hooks |
updatedPerPage() | Per-page value changes | Lifecycle Hooks |
Public Methods
Method | Parameters | Purpose | Details |
---|---|---|---|
clearFilters() | - | Clear all filters | Public Methods |
clearFilter($field) | string $field | Clear specific filter | Public Methods |
sortBy($field) | string $field | Toggle sort on field | Auto-wired to column headers |
Helper Methods
Method | Returns | Purpose |
---|---|---|
getColumns() | array | Get all columns (respects precedence) |
addColumn($field, $label = null) | Column | Create new column (static) |
addActions($actions, $label = 'Actions') | Column | Create 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
Method | Parameters | Returns | Purpose | Details |
---|---|---|---|---|
field($field) | string | Column | Set database field name | Basic Methods |
sortable($sortable) | bool = true | Column | Enable/disable sorting | Basic Methods |
filter($type, $options...) | mixed | Column | Set filter type and options | Overriding Inferred Filters |
display($callback) | callable | Column | Custom value rendering | display() |
cellClass($classes) | string\|callable | Column | Cell CSS classes | cellClass() |
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
Method | Returns | Purpose | Details |
---|---|---|---|
hasFilter() | bool | Check if column has filter | Helper Methods |
hasDisplay() | bool | Check if has custom display | Helper Methods |
renderValue($record) | mixed | Render column value | Helper Methods |
getVisibleActions($record) | array | Get visible actions for record | Helper Methods |
Getter Methods
Method | Returns | Purpose |
---|---|---|
getLabel() | string | Get column label |
getName() | string | Get column name (normalized) |
getField() | string | Get database field name |
getFilter() | ?FilterInterface | Get filter instance |
getCellClass($record) | string | Get cell CSS classes |
isSortable() | bool | Check if sortable |
isActionsColumn() | bool | Check if actions column |
Internal Methods
Method | Purpose |
---|---|
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...']
NumericFilter
NumericFilter::make(string $operator = '=', array $options = []): self
// Operators
'=', '!=', 'not-=', '>', '>=', '<', '<='
// Options
['placeholder' => 'Enter number...', 'min' => null, 'max' => null, 'step' => 'any']
DateFilter
DateFilter::make(string $operator = '=', array $options = []): self
// Operators
'=', '!=', 'not-=', '>', '>=', '<', '<='
// Options
['placeholder' => 'Select date...', 'format' => 'Y-m-d']
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']
NumericRangeFilter
NumericRangeFilter::make(array $options = []): self
// Value format
['min' => 10, 'max' => 100]
// or
[10, 100]
// Options
['placeholder_min' => 'Min', 'placeholder_max' => 'Max', 'step' => 'any']
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]
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
}
Configuration Options
Global settings in config/slick-filters.php
:
Option | Type | Default | Overridable | Details |
---|---|---|---|---|
enable_search | bool | false | Yes (per component) | Configuration |
per_page_options | array | [10, 25, 50, 100] | Yes (getPerPageOptions() ) | Configuration |
default_per_page | int | 10 | Yes (public int $perPage ) | Configuration |
default_sort_direction | string | 'asc' | Yes (public string $sortDirection ) | Configuration |
debounce_delay | int | 300 | ❌ No (global only) | Configuration |
theme | string | 'default' | ❌ Unused feature | Configuration |
classes | array | See config | Publish views | Configuration |
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()
, andquery()
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:
- No database indexes on filtered columns
- Global search across too many columns
- N+1 query problems with relationships
- 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
Issue | Cause | Solution |
---|---|---|
Case-sensitive not working | BINARY MySQL-only | Use collation or like-* operators |
DateTime not filtering | whereDate() strips time | Use DateRangeFilter |
Relationships not filtering | No auto-inference for dot notation | Explicit filter + joins |
"0" treated as empty | Intentional (valid value) | Use clearFilter() |
Snake_case mismatch | Auto-normalization | Use ->field() for exact name |
Slow performance | Missing indexes | Add DB indexes, limit columns |
Multi-table conflicts | Shared component IDs | Use unique key attributes |
License
MIT License
Credits
Created by Brandon Moore brandon@i-tul.com