Search in textstem-laravel
There are two distinct search mechanisms in this package:
- CMS Admin Search — a Livewire-powered global search over the admin panel, backed by SQL
LIKEqueries - Scout Integration — Laravel Scout wired up on
PostandWranglerPagefor optional external search engine indexing (Meilisearch, Algolia, etc.)
1. CMS Admin Search
How it works
The admin search lives at /textstem/search, rendered by SearchController::index() which loads the CmsSearch Livewire component. Search is entirely database-driven — no external index required.
Key files:
| File | Purpose |
|---|---|
src/Livewire/CmsSearch.php |
Core search logic |
src/Models/Scopes/Searchable.php |
Reusable search / searchLatestPaginated query scopes |
resources/views/livewire/cms-search.blade.php |
Search UI with debounce, type filters, results |
config/textstem.php (search key) |
Configures which models are searched and how |
Search flow
- User types in the search input (min 2 chars, 350ms debounce via
wire:model.live.debounce.350ms) - The
results()computed property fires inCmsSearch - For each model in
textstemapp.search.models, it builds a query withOR LIKE '%term%'across the configured fields - Up to
per_typeresults are returned, ordered byupdated_at DESC - Results are grouped by model type, each group carrying a
totalcount and ahas_moreflag - The user can filter by type via the type pills, which call
setType(string $type)
Configuration
In config/textstem.php (published to the host app as config/textstemapp.php):
'search' => [
'per_type' => 10, // max results per model type shown in the UI
'models' => [
[
'model' => Post::class,
'label' => 'Posts', // UI label and group heading
'fields' => ['title', 'description', 'content', 'keywords'], // searched with LIKE
'title_field' => 'title', // field used as the result title
'subtitle_field' => 'post_type', // field used as the result subtitle (optional)
'route' => 'posts.edit', // named route — receives the model id
],
// ... add more models here
],
],
Default configured models:
| Model | Searched fields | Route |
|---|---|---|
Post |
title, description, content, keywords | posts.edit |
WranglerPage |
title, description, keywords, slug, url | wrangler-pages.edit |
Asset |
title, caption, description | assets.edit |
Category |
name, type | categories.edit |
GlobalOption |
key, display_name, value | global_options.edit |
Adding a model to CMS search
Merge into the search.models array in the host app's published config:
[
'model' => \App\Models\Product::class,
'label' => 'Products',
'fields' => ['name', 'sku', 'description'],
'title_field' => 'name',
'subtitle_field' => 'sku',
'route' => 'products.edit',
],
The route is resolved with route($routeName, $model->id) — it must accept the model's ID as its first parameter. If the route doesn't exist, the result is shown without a link.
Wildcard fields ('*')
Setting 'fields' => ['*'] causes the search to query every column on the model's table. This is resolved at runtime via getSchemaBuilder()->getColumnListing(). Convenient, but can be slow on wide tables — prefer an explicit field list for production.
The Searchable trait
Medialight\Textstem\Models\Scopes\Searchable (src/Models/Scopes/Searchable.php) is a trait that can be used on any Eloquent model to expose two query scopes:
// Search across $searchableFields, return a Builder
$query->search('my term');
// Search + order by updated_at DESC + paginate
$query->searchLatestPaginated('my term', perPage: 15);
The fields searched are controlled by a $searchableFields property on the model:
protected array $searchableFields = ['title', 'slug', 'description'];
If $searchableFields is absent, empty, or ['*'], all table columns are searched automatically.
This trait is used independently of the CMS search config — it's useful for in-controller searches (e.g. an admin list page with a search input).
URL state
CmsSearch uses #[Url(history: true)] on both $query and $activeType, so the search term and active type filter are reflected in the browser URL and survive back/forward navigation.
2. Scout Integration
Overview
laravel/scout (^10.5) is installed and wired up on two models: Post and WranglerPage. Scout provides an abstraction layer over external search engines (Meilisearch, Algolia, Typesense, etc.) and is configured independently of the CMS admin search above.
Note: Scout is configured but not the primary driver of the admin search UI. It is intended for front-end / public-facing search features in the host application.
Models
WranglerPage
Implements both indexing conditions and a custom index payload:
// Only index pages that are published, public, and listed
public function shouldBeSearchable(): bool
{
if (config('pages.searchable', 0)) {
return $this->enabled
&& $this->access === 'public'
&& $this->listed == 1;
}
return false;
}
// What gets indexed — aggregates all enabled component content
public function toSearchableArray(): array
{
$content = '';
foreach ($this->wranglerComponents as $component) {
if ($component->enabled) {
$content .= $component->title . "\r";
$content .= $component->content . "\r";
}
}
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'url' => empty($this->canonical_url) ? $this->url : $this->canonical_url,
'thumbnail' => $this->thumbnail,
'description' => $this->description,
'keywords' => $this->keywords,
'content' => $content,
];
}
Key point: content in the indexed payload is a concatenation of all enabled WranglerComponent titles and content, not a raw database column. This makes pages fully searchable by their rendered content without storing a flat copy.
Post
Uses the Laravel\Scout\Searchable trait. A post is indexed only when all of these are true:
- its collection has Searchable turned on (
collections.searchable); - it is published, and its visibility (
post_status) ispublic; - it is not a draft of a live post (see Content approval).
The indexed record holds id (as a string), title, slug, url (the canonical URL), post_type, description, keywords, content, published_at, thumbnail, meta and created_at (as a Unix timestamp, for Typesense).
Enabling Scout indexing
Page indexing is switched by config('pages.searchable'), which the package doesn't provide. Create config/pages.php in the host app:
<?php
// config/pages.php
return [
'searchable' => 1,
];
Without it, WranglerPage::shouldBeSearchable() always returns false and no pages are indexed. Posts don't read this setting; they follow their collection's Searchable switch (see Post above).
Then configure a Scout driver in config/scout.php (standard Laravel Scout setup):
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://localhost:7700
MEILISEARCH_KEY=your-key
Index/re-index from the host app:
php artisan scout:import "Medialight\Textstem\Models\WranglerPage"
php artisan scout:import "Medialight\Textstem\Models\Post"
Summary: Which search to use when
| Use case | Mechanism |
|---|---|
| Admin panel global search | CMS Admin Search (Livewire + LIKE) |
| Per-model admin list with search input | Searchable trait + scopeSearch() |
| Public-facing site search | Scout (external engine) |
| Searching a custom model in the admin panel | Add it to textstemapp.search.models |