Every filter follows this pattern:
Filter::method($attribute, $requestParameter)
// ↑ ↑
// database column URL param nameParameter 1: $attribute (required)
- The database column name you want to filter
- Example:
'status','price','created_at'
Parameter 2: $requestParameter (optional)
- The URL query parameter name
- Defaults to
$attributeif omitted - Example:
'product_status','min_price','date'
Filter::exact('status', 'product_status')URL:
GET /api/products?filter[product_status]=active
↑ parameter ↑ value
SQL:
WHERE status = 'active'
↑ column ↑ valueFilter::fullText($columns, $requestParameter)WHY: Defines which database columns to search in
Option A: Array (Multiple Columns)
Filter::fullText(['name', 'description', 'tags'], 'q')
// ↑ ↑ ↑ ↑
// column 1 column 2 column 3 request paramWhat it does:
- Searches across ALL specified columns
- Finds matches in ANY column
- More comprehensive search results
PostgreSQL SQL:
WHERE (
to_tsvector('simple', COALESCE(name, '')) ||
to_tsvector('simple', COALESCE(description, '')) ||
to_tsvector('simple', COALESCE(tags, ''))
) @@ to_tsquery('simple', 'search:*')MySQL/SQLite SQL:
WHERE (
name LIKE '%search%' OR
description LIKE '%search%' OR
tags LIKE '%search%'
)Option B: String (Single Column)
Filter::fullText('name', 'q')
// ↑ ↑
// column paramWhat it does:
- Searches in ONE column only
- Faster than multiple columns
- Use when you know which field to search
Option C: search_vector (Pre-computed)
Filter::fullText('search_vector', 'q')
// ↑
// special indexed columnWhat it does:
- Uses pre-processed text column
- 10-100x faster than regular columns
- Requires database setup (PostgreSQL only)
Filter::fullText(['name', 'description'], 'q')
// ↑
// request param nameURL:
GET /api/products?filter[q]=laptop
↑ ↑
param value
Default: 'search' if omitted
Filter::fullText(['name', 'description'])
// Same as:
Filter::fullText(['name', 'description'], 'search')WHY: Different languages have different word stems
Syntax:
->setFullTextLanguage('language_name')What is stemming?
- English: "running" → "run", "runner" → "run"
- Portuguese: "correndo" → "corr", "correr" → "corr"
Example:
Filter::fullText(['title', 'content'], 'q')
->setFullTextLanguage('portuguese')Available Languages (PostgreSQL):
'simple'- No stemming (default)'english'- English rules'portuguese'- Portuguese rules'spanish'- Spanish rules'french'- French rules'german'- German rules- More... (depends on PostgreSQL installation)
PostgreSQL SQL:
WHERE (
to_tsvector('portuguese', title) ||
to_tsvector('portuguese', content)
) @@ to_tsquery('portuguese', 'search:*')
-- ↑ language applied hereNote: MySQL/SQLite ignore language setting
To control the default language globally, you need to create an environment variable:
Step 1: Add to config/app.php
return [
// ... other settings
'fulltext_language' => env('FULLTEXT_LANGUAGE', 'simple'),
];Step 2: Add to .env file
FULLTEXT_LANGUAGE=portugueseStep 3: Use without specifying language
// Automatically uses 'portuguese' from .env
Filter::fullText(['title', 'content'], 'q')
// No need for ->setFullTextLanguage()Priority Order:
->setFullTextLanguage('english')→ Uses'english'(overrides .env).env→FULLTEXT_LANGUAGE=portuguese→ Uses'portuguese'- No config → Uses
'simple'(PostgreSQL default)
Example:
// Uses .env setting (e.g., 'portuguese')
Filter::fullText(['name', 'description'], 'search')
// Overrides .env and uses 'english'
Filter::fullText(['name', 'description'], 'search')
->setFullTextLanguage('english')WHY: Control whether partial words match
Syntax:
->setFullTextPrefixMatch(true|false)What is prefix matching?
Enabled (default):
->setFullTextPrefixMatch(true)"lap"matches: "lap", "laptop", "lapel"- Uses wildcard
*in PostgreSQL
Disabled:
->setFullTextPrefixMatch(false)"lap"matches: "lap" only- No wildcard in PostgreSQL
Example:
Filter::fullText(['name'], 'q')
->setFullTextPrefixMatch(true) // DefaultURL:
GET /api/products?filter[q]=test
Matches:
- "test" ✓
- "testing" ✓
- "tester" ✓
PostgreSQL SQL:
WHERE to_tsvector('simple', name) @@ to_tsquery('simple', 'test:*')
-- ↑ wildcardWHAT: Pre-computed column for ultra-fast searches
WHY:
- 10-100x faster than regular columns
- Scales to millions of rows
- Updates automatically via trigger
Step 1: Create Migration
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
public function up()
{
// 1. Add tsvector column
Schema::table('products', function (Blueprint $table) {
$table->tsvector('search_vector')->nullable();
});
// 2. Create GIN index (fast searching)
DB::statement('
CREATE INDEX products_search_vector_idx
ON products
USING GIN(search_vector)
');
// 3. Create trigger (auto-update on insert/update)
DB::statement("
CREATE TRIGGER products_search_vector_update
BEFORE INSERT OR UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION
tsvector_update_trigger(
search_vector, -- column to update
'pg_catalog.portuguese', -- language
name, -- columns to index
description,
sku
);
");
}WHAT EACH PART DOES:
- tsvector column: Stores processed text
- GIN index: Makes searches blazing fast
- Trigger: Auto-updates search_vector when data changes
Step 2: Use in Filter
Filter::fullText('search_vector', 'q')
// ↑
// column name (not array!)
->setDatabaseDriver('pgsql')URL:
GET /api/products?filter[q]=macbook pro
PostgreSQL SQL:
WHERE search_vector @@ websearch_to_tsquery('simple', 'macbook pro')
-- ↑ indexed! ↑ optimized functionPerformance Comparison:
| Method | 1M Rows | Notes |
|---|---|---|
| Regular columns | ~500ms | Slow, scans all rows |
| search_vector | ~5ms | 100x faster! |
WHY SO FAST?
- ✅ Pre-processed text (no to_tsvector() on every query)
- ✅ GIN index lookup (instant)
- ✅ Optimized query function (websearch_to_tsquery)
Filter::fullText(['title', 'content', 'excerpt'], 'search')WHAT:
- Columns:
title,content,excerpt - Request param:
filter[search] - Language:
simple(default) - Prefix match:
true(default)
URL:
GET /api/posts?filter[search]=laravel framework
PostgreSQL SQL:
WHERE (
to_tsvector('simple', COALESCE(title, '')) ||
to_tsvector('simple', COALESCE(content, '')) ||
to_tsvector('simple', COALESCE(excerpt, ''))
) @@ to_tsquery('simple', 'laravel:* & framework:*')Filter::fullText(['title', 'body'], 'q')
->setFullTextLanguage('portuguese') // Portuguese stemming
->setFullTextPrefixMatch(false) // No wildcardsWHAT:
- Language: Portuguese word stems
- Prefix match: Disabled (exact words only)
URL:
GET /api/articles?filter[q]=desenvolvimento software
PostgreSQL SQL:
WHERE (
to_tsvector('portuguese', COALESCE(title, '')) ||
to_tsvector('portuguese', COALESCE(body, ''))
) @@ to_tsquery('portuguese', 'desenvolvimento & software')
-- ↑ Portuguese stemming ↑ no wildcardsMATCHES:
- "desenvolvimento de software" ✓
- "desenvolver software" ✓ (stem matches)
- "desenvolv" ✗ (prefix match disabled)
Filter::fullText('search_vector', 'q')
->setDatabaseDriver('pgsql')WHAT:
- Uses pre-computed
search_vectorcolumn - GIN indexed for speed
- Best for large datasets
URL:
GET /api/products?filter[q]=macbook pro 13
PostgreSQL SQL:
WHERE search_vector @@ websearch_to_tsquery('simple', 'macbook pro 13')PERFORMANCE:
- Regular: 500ms (1M rows)
- search_vector: 5ms (1M rows)
- 100x faster!
PURPOSE: Exact match (SQL =)
SYNTAX:
Filter::exact($column, $requestParam)EXAMPLE:
Filter::exact('status', 'order_status')URL:
GET /api/orders?filter[order_status]=pending
SQL:
WHERE status = 'pending'USE CASES:
- Status matching
- ID lookups
- Boolean flags
- Enum values
PURPOSE: Pattern matching with custom wildcards
SYNTAX:
Filter::like($column, $requestParam)
->setLikePattern($pattern) // OptionalPARAMETER: setLikePattern()
{{value}}= placeholder for search term%= wildcard (any characters)
DEFAULT PATTERN: '%{{value}}%' (contains)
CUSTOM PATTERNS:
a) Starts With:
Filter::like('name', 'prefix')
->setLikePattern('{{value}}%')
// ↑ term ↑ wildcardURL:
GET /api/products?filter[prefix]=mac
SQL:
WHERE name LIKE 'mac%'
-- Matches: "macbook", "mac pro"
-- No match: "imac" (doesn't start with 'mac')b) Ends With:
Filter::like('email', 'domain')
->setLikePattern('%{{value}}')
// ↑ wildcard ↑ termURL:
GET /api/users?filter[domain]=@gmail.com
SQL:
WHERE email LIKE '%@gmail.com'
-- Matches: "user@gmail.com", "admin@gmail.com"c) Contains (Default):
Filter::like('description', 'search')
// Pattern: '%{{value}}%' (default)URL:
GET /api/products?filter[search]=laptop
SQL:
WHERE description LIKE '%laptop%'
-- Matches anywhere in textPURPOSE: Case-insensitive LIKE
SYNTAX:
Filter::ilike($column, $requestParam)DATABASE BEHAVIOR:
| Database | How it works |
|---|---|
| PostgreSQL | name ILIKE '%value%' (native) |
| SQLite | name LIKE '%value%' (already case-insensitive) |
| MySQL | LOWER(name) LIKE LOWER('%value%') |
EXAMPLE:
Filter::ilike('name', 'search')URL:
GET /api/products?filter[search]=LAPTOP
MATCHES:
- "laptop" ✓
- "Laptop" ✓
- "LAPTOP" ✓
- "LaPtOp" ✓
PURPOSE: Matches values that start with prefix
SYNTAX:
Filter::startsWith($column, $requestParam)EXAMPLE:
Filter::startsWith('sku', 'code')URL:
GET /api/products?filter[code]=PRD
SQL:
WHERE sku LIKE 'PRD%'
-- Matches: "PRD001", "PRD-LAPTOP"
-- No match: "PRODUCT-001"PURPOSE: Matches values that end with suffix
SYNTAX:
Filter::endsWith($column, $requestParam)EXAMPLE:
Filter::endsWith('filename', 'ext')URL:
GET /api/files?filter[ext]=.pdf
SQL:
WHERE filename LIKE '%.pdf'
-- Matches: "document.pdf", "report.pdf"
-- No match: "document.pdf.zip"PURPOSE: Exclude pattern matches
SYNTAX:
Filter::notLike($column, $requestParam)
->setLikePattern($pattern) // OptionalEXAMPLE:
Filter::notLike('email', 'exclude')
->setLikePattern('%{{value}}')URL:
GET /api/users?filter[exclude]=@spam.com
SQL:
WHERE email NOT LIKE '%@spam.com'
-- Excludes any email ending with @spam.comPURPOSE: Match any value in list (SQL IN)
SYNTAX:
Filter::in($column, $requestParam)REQUEST FORMATS:
a) Comma-separated:
GET /api/products?filter[categories]=1,2,3
↑ automatically split to array
b) Array:
GET /api/products?filter[categories][]=1&filter[categories][]=2
EXAMPLE:
Filter::in('category_id', 'categories')SQL:
WHERE category_id IN (1, 2, 3)USE CASES:
- Multiple categories
- Multiple statuses
- Tag filtering
PURPOSE: Exclude values from list
SYNTAX:
Filter::notIn($column, $requestParam)EXAMPLE:
Filter::notIn('status', 'exclude_statuses')URL:
GET /api/orders?filter[exclude_statuses]=cancelled,refunded
SQL:
WHERE status NOT IN ('cancelled', 'refunded')PURPOSE: Range filter (SQL BETWEEN)
SYNTAX:
Filter::between($column, $requestParam)REQUEST FORMAT: value1,value2
EXAMPLE:
Filter::between('price', 'price_range')URL:
GET /api/products?filter[price_range]=100,500
↑ ↑
min max
SQL:
WHERE price BETWEEN 100 AND 500USE CASES:
- Price ranges
- Date ranges
- Age ranges
PURPOSE: Comparison operators
SYNTAX:
Filter::gt($column, $param) // > Greater than
Filter::gte($column, $param) // >= Greater or equal
Filter::lt($column, $param) // < Less than
Filter::lte($column, $param) // <= Less or equalEXAMPLES:
Minimum price:
Filter::gte('price', 'min_price')URL:
GET /api/products?filter[min_price]=100
SQL:
WHERE price >= 100Maximum age:
Filter::lte('age', 'max_age')URL:
GET /api/users?filter[max_age]=65
SQL:
WHERE age <= 65PURPOSE: Not equal (SQL !=)
SYNTAX:
Filter::notEquals($column, $requestParam)EXAMPLE:
Filter::notEquals('status', 'exclude_status')URL:
GET /api/products?filter[exclude_status]=discontinued
SQL:
WHERE status != 'discontinued'PURPOSE: Check for NULL values
SYNTAX:
Filter::isNull($column, $requestParam)EXAMPLE:
Filter::isNull('deleted_at', 'show_deleted')URL:
GET /api/posts?filter[show_deleted]=1
↑ any value triggers filter
SQL:
WHERE deleted_at IS NULLNOTE: Request value doesn't matter, presence applies filter
PURPOSE: Check for NOT NULL values
SYNTAX:
Filter::isNotNull($column, $requestParam)EXAMPLE:
Filter::isNotNull('email_verified_at', 'verified')URL:
GET /api/users?filter[verified]=1
SQL:
WHERE email_verified_at IS NOT NULLUSE CASES:
- Verified users
- Published posts
- Completed orders
PURPOSE: Filter JSON column fields
SYNTAX:
Filter::json($jsonColumn, $path, $operator, $requestParam)
// ↑ ↑ ↑ ↑
// JSON column path operator param name
->setDatabaseDriver($driver) // Required!PARAMETERS:
$jsonColumn- JSON column name$path- Dot-notation path ('user.name')$operator- SQL operator ('=', 'LIKE', '>')$requestParam- URL parameter name
ALWAYS SET DRIVER:
->setDatabaseDriver('pgsql') // or 'mysql', 'sqlite'EXAMPLES:
a) Exact match:
Filter::json('attributes', 'color', '=', 'color')
->setDatabaseDriver('pgsql')URL:
GET /api/products?filter[color]=red
PostgreSQL SQL:
WHERE attributes->>'color' = 'red'b) Nested path:
Filter::json('metadata', 'dimensions.width', '>', 'min_width')
->setDatabaseDriver('mysql')URL:
GET /api/products?filter[min_width]=50
MySQL SQL:
WHERE metadata->>'$.dimensions.width' > 50c) Pattern match:
Filter::json('specs', 'material', 'LIKE', 'material')
->setDatabaseDriver('pgsql')URL:
GET /api/products?filter[material]=cotton
SQL:
WHERE specs->>'material' LIKE '%cotton%'PURPOSE: Filter by related model
SYNTAX:
Filter::relationship($relation, $column, $operator, $requestParam)
// ↑ ↑ ↑ ↑
// relationship column operator param name
->with() // Eager load (optional but recommended)PARAMETERS:
$relation- Laravel relationship name$column- Column in related table$operator- SQL operator (default '=')$requestParam- URL parameter name
METHOD: ->with()
- Eager loads the relationship
- Prevents N+1 queries
- Always use for better performance
EXAMPLES:
a) Simple relationship:
Filter::relationship('category', 'slug', '=', 'category')
->with()URL:
GET /api/products?filter[category]=electronics
SQL:
WHERE EXISTS (
SELECT * FROM categories
WHERE categories.id = products.category_id
AND categories.slug = 'electronics'
)b) whereAny (OR logic):
SYNTAX:
->whereAny([
[$column, $operator, $value],
[$column, $operator, $value], // OR
])EXAMPLE:
Filter::relationship('tags', 'name')
->whereAny([
['name', '=', 'sale'], // condition 1
['name', '=', 'featured'], // OR condition 2
])
->with()WHAT: Product has tag 'sale' OR 'featured'
SQL:
WHERE EXISTS (
SELECT * FROM tags
INNER JOIN product_tag ON tags.id = product_tag.tag_id
WHERE product_tag.product_id = products.id
AND (tags.name = 'sale' OR tags.name = 'featured')
)c) whereAll (AND logic):
SYNTAX:
->whereAll([
[$column, $operator, $value],
[$column, $operator, $value], // AND
])EXAMPLE:
Filter::relationship('user', 'id')
->whereAll([
['id', '=', auth()->id()], // condition 1
['active', '=', true], // AND condition 2
['verified', '=', true], // AND condition 3
])
->with()WHAT: User must match ALL conditions
SQL:
WHERE EXISTS (
SELECT * FROM users
WHERE users.id = posts.user_id
AND users.id = 123
AND users.active = 1
AND users.verified = 1
)d) whereNone (NOT logic):
SYNTAX:
->whereNone([
[$column, $operator, $value],
])EXAMPLE:
Filter::relationship('tags', 'name')
->whereNone([
['name', '=', 'banned'],
['name', '=', 'spam'],
])WHAT: Product has NO tags named 'banned' or 'spam'
SQL:
WHERE NOT EXISTS (
SELECT * FROM tags
INNER JOIN product_tag ON tags.id = product_tag.tag_id
WHERE product_tag.product_id = products.id
AND (tags.name = 'banned' OR tags.name = 'spam')
)PURPOSE: Handle dates with automatic conversion
SYNTAX:
Filter::exact($column, $requestParam)
->castDate() // Convert string → Carbon
->startOfDay() // Set to 00:00:00 (optional)
->endOfDay() // Set to 23:59:59 (optional)METHODS:
->castDate()- Convert to Carbon instance->startOfDay()- Time = 00:00:00->endOfDay()- Time = 23:59:59
EXAMPLES:
a) Start of day:
Filter::exact('created_at', 'date')
->castDate()
->startOfDay()URL:
GET /api/orders?filter[date]=2024-01-15
SQL:
WHERE created_at BETWEEN
'2024-01-15 00:00:00' AND
'2024-01-15 23:59:59'b) End of day:
Filter::exact('deadline', 'due')
->castDate()
->endOfDay()URL:
GET /api/tasks?filter[due]=2024-12-31
SQL:
WHERE deadline BETWEEN
'2024-12-31 00:00:00' AND
'2024-12-31 23:59:59'use DevactionLabs\FilterablePackage\Filter;
$products = Product::query()
->filtrable([
// Search product name (starts with)
Filter::like('name', 'search')
->setLikePattern('{{value}}%'),
// ↑ no prefix wildcard = starts with
// Price range
Filter::between('price', 'price_range'),
// ↑ ↑
// column name request param
// Multiple categories
Filter::in('category_id', 'categories'),
// ↑ ↑
// column name accepts: "1,2,3"
// Exclude discontinued
Filter::notEquals('status', 'exclude_status'),
// Filter by brand (relationship)
Filter::relationship('brand', 'slug', '=', 'brand')
// ↑ ↑ ↑ ↑
// relation column op param
->with(), // Eager load brand
// Products on sale OR featured
Filter::relationship('tags', 'name')
->whereAny([ // OR logic
['name', '=', 'sale'],
['name', '=', 'featured'],
])
->with(),
// Full-text search
Filter::fullText(['name', 'description', 'sku'], 'q')
// ↑ ↑ ↑ ↑
// search in these columns param
->setFullTextLanguage('portuguese'),
])
->customPaginate('paginate', 20);Example URLs:
# Basic search
GET /api/products?filter[search]=laptop
# Search + price range
GET /api/products?filter[search]=laptop&filter[price_range]=1000,3000
# Multiple filters
GET /api/products?filter[categories]=1,2&filter[brand]=apple&filter[exclude_status]=discontinued
# Full-text search
GET /api/products?filter[q]=macbook pro retina&sort=-price$posts = Post::query()
->filtrable([
// Full-text search in multiple fields
Filter::fullText(['title', 'content', 'excerpt'], 'search')
->setFullTextLanguage('portuguese')
->setFullTextPrefixMatch(true),
// Status filter
Filter::exact('status', 'status'),
// Date range
Filter::between('published_at', 'date_range'),
// Author filter
Filter::relationship('author', 'username', '=', 'author')
->with(),
// Posts with ALL these categories
Filter::relationship('categories', 'slug')
->whereAll([ // AND logic
['slug', '=', request('filter.category')],
['is_active', '=', true],
])
->with(),
// Only published
Filter::isNotNull('published_at', 'published'),
])
->customPaginate('cursor', 10);Example URLs:
# Search published posts
GET /api/posts?filter[search]=laravel&filter[published]=1
# By author and date range
GET /api/posts?filter[author]=john&filter[date_range]=2024-01-01,2024-12-31
# Full-text search
GET /api/posts?filter[search]=desenvolvimento web&sort=-published_at// ❌ Slow on 1M+ rows
Filter::fullText(['title', 'content'], 'q')
// ✅ Fast even with millions of rows
Filter::fullText('search_vector', 'q')
->setDatabaseDriver('pgsql')// ❌ Causes N+1 queries
Filter::relationship('category', 'slug') // Missing ->with()
// ✅ Prevents N+1 queries
Filter::relationship('category', 'slug')
->with() // Eager loads relationship// ❌ May not work correctly
Filter::json('data', 'path') // Missing ->setDatabaseDriver()
// ✅ Works correctly
Filter::json('data', 'path')
->setDatabaseDriver('pgsql')// ❌ Slow on large tables (counts all rows)
->customPaginate('paginate', 20)
// ✅ Fast (no total count)
->customPaginate('cursor', 20)-- For frequently filtered columns
CREATE INDEX idx_products_price ON products(price);
CREATE INDEX idx_products_status ON products(status);
-- For full-text (PostgreSQL)
CREATE INDEX idx_products_search_vector
ON products USING GIN(search_vector);Wrong:
Filter::relationship('tags', 'name')
->whereAny([
['name', '=', 'sale'],
])
->setValue('custom') // ❌ Has no effectCorrect:
Filter::relationship('tags', 'name')
->whereAny([
['name', '=', 'sale'], // ✅ Condition already defined
])
->with()Wrong:
Filter::relationship('user', 'id')
->whereAll([
['active', '=', true],
])
->setValue(auth()->id()) // ❌ Doesn't filter by IDCorrect:
Filter::relationship('user', 'id')
->whereAll([
['id', '=', auth()->id()], // ✅ Dynamic value in array
['active', '=', true],
])
->with()Wrong:
Filter::exact('created_at', 'date')
->endOfDay() // ❌ Needs Carbon instanceCorrect:
Filter::exact('created_at', 'date')
->castDate() // ✅ Convert to Carbon first
->endOfDay()See also:
- README.md - Main documentation
- CHANGELOG.md - Version history