Search in textstem-laravel

There are two distinct search mechanisms in this package:

  1. CMS Admin Search — a Livewire-powered global search over the admin panel, backed by SQL LIKE queries
  2. Scout Integration — Laravel Scout wired up on Post and WranglerPage for optional external search engine indexing (Meilisearch, Algolia, etc.)

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

  1. User types in the search input (min 2 chars, 350ms debounce via wire:model.live.debounce.350ms)
  2. The results() computed property fires in CmsSearch
  3. For each model in textstemapp.search.models, it builds a query with OR LIKE '%term%' across the configured fields
  4. Up to per_type results are returned, ordered by updated_at DESC
  5. Results are grouped by model type, each group carrying a total count and a has_more flag
  6. 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

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) is public;
  • 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
esc