protonemedia/laravel-cross-eloquent-search
Search across multiple Eloquent models with one query. Supports pagination, scoped constraints, eager loading, relationship and nested search, full-text search, cross-model sorting, and ordering by relevance. Works with MySQL, PostgreSQL, and SQLite.
Install the package with composer require protonemedia/laravel-cross-eloquent-search, then start with the basic search across two models:
use ProtoneMedia\LaravelCrossEloquentSearch\Search;
$results = Search::add(Post::class, 'title')
->add(Video::class, 'title')
->search('laravel');
The first use case is typically building a unified search endpoint (e.g., /api/search?q=...) where frontends need a single paginated list of mixed content types. The Search::add() method lets you declare which models and columns to include — the package handles the underlying UNION queries, relevance scoring, and pagination automatically.
Check the README’s usage examples and the linked YouTube stream (starting at 13:44) for a live walkthrough.
Centralized Search Service: Wrap the builder in a dedicated SearchService class to encapsulate common patterns (e.g., role-based model access, tenant filtering). Use when() to conditionally add models based on auth context:
Search::new()
->when($user->isAdmin(), fn($s) => $s->add(InternalNote::class, 'content'))
->add(Post::class, 'title')
->add(Video::class, 'title')
->search($request->input('q'));
Scoped Queries with Constraints: Pre-filter models using scopes or where clauses passed directly to add():
Search::add(Post::published(), 'title')
->add(Comment::active(), 'body')
->search($term);
Relevance + Date Sorting: Combine orderByRelevance() with fallback columns (e.g., published_at) for a nuanced result ordering:
Search::add(Post::class, 'title')
->add(Video::class, ['title', 'description'])
->beginWithWildcard()
->orderByRelevance()
->orderByDesc('published_at')
->paginate(20)
->search($term);
Relationship-aware search: Use dot notation for nested column search across relations, often paired with eager loading:
Search::add(Post::with(['author', 'comments.user']), 'comments.body')
->search('helpful reply');
Full-text + sound-alike fallback: For production search, combine addFullText() (for performance) with soundsLike() as a fallback for typos:
$search = Search::new()
->addFullText(Post::class, 'title', ['mode' => 'boolean'])
->add(Post::class, 'title')
->soundsLike();
$term = $request->input('q');
$results = $search->search($term);
Paginated API responses: Return paginated results directly in controllers — includes total count and page links:
return Search::add(Post::class, 'title')
->add(Video::class, 'title')
->paginate()
->withQueryString()
->search($request->q);
Database driver matters: Full-text and SOUNDS LIKE behave differently per DB: MySQL uses native full-text, PostgreSQL requires pg_trgm, and SQLite uses LIKE fallback. Always test across environments — especially for similarity matching or boolean mode.
Wildcard defaults: The package splits input and adds trailing wildcards by default (e.g., laravel → laravel%). To match substrings anywhere (%lar%), call beginWithWildcard(). Disable wildcards with endWithWildcard(false) for exact matching (like exactMatch()).
Timestamps & ordering: By default, results are sorted by updated_at or primary key if no timestamps exist. Override with orderBy('column') per model after add() — not as a third argument to add() (that’s for relevance scoring column, not sort column).
Nested relationship search pitfalls: Search on nested relations (e.g., comments.body) will join tables, but orderByRelevance() is not supported for such queries — use orderBy('column') instead.
Eager loading is per-model: When using with(), ensure the relationship exists on the base model, not just in your result set (e.g., if a Video lacks comments, no join occurs for it).
Empty or missing search term: Passing null or empty string to search() returns all records (respecting constraints), ordered by your chosen column. Use when() or request validation to block empty searches if undesirable.
Pagination limits: simplePaginate() avoids COUNT(*) over the full union, which is faster for large datasets — ideal for infinite scroll. Use withQueryString() to preserve filters in pagination links.
Include model type: Call includeModelType() before pagination to add the model class name to each result item — invaluable for rendering mixed UIs (e.g., <search-result :type="item.model_type">).
How can I help you explore Laravel packages today?