Search with Typesense
Architecture decision
The overall search strategy (PostGIS for geography + Typesense for full-text) is captured in ADR 0005: Search architecture (PostGIS + Typesense).
Overview
Wedissimo uses Typesense 29.0 via Laravel Scout for full-text search. Models automatically sync to Typesense on create/update/delete operations.
Features
- Fast, typo-tolerant full-text search
- Faceted filtering (categories, tags, price ranges, etc.)
- Geo-location search with radius filtering
- Sortable fields
- Case-insensitive search
- Real-time index updates
- Pagination support
Searchable Models
App\Models\UserApp\Models\Listing(19 fields) - Core modelModules\Vendor\Models\Vendor(17 fields) - Vendor moduleModules\UserInvitation\Models\UserInvitation- UserInvitation module
Local Development Setup
1. Start Typesense Service
Typesense runs as a Docker container and starts automatically with docker compose up:
docker compose up -dVerify Typesense is running:
# Check container status
docker compose ps wedissimo-typesense
# Test Typesense API
curl http://localhost:2226/health \
-H "X-TYPESENSE-API-KEY: your-typesense-api-key-change-in-production"2. Import Initial Data
After running migrations or importing WordPress data, you must import records into Typesense:
# Import all searchable models
docker compose exec -T wedissimo-api php artisan typesense:reindex --all
# Or import specific models
docker compose exec -T wedissimo-api php artisan scout:import "Modules\Listing\Models\Listing"
docker compose exec -T wedissimo-api php artisan scout:import "Modules\Vendor\Models\Vendor"
docker compose exec -T wedissimo-api php artisan scout:import "App\Models\User"Important: The WordPress migration commands (wedissimo:migrate-listings, wedissimo:migrate-vendors) use withoutEvents() to improve performance, which means Scout indexing doesn't happen automatically. You must run scout:import after migrations complete.
3. Verify Data is Indexed
Check collection document counts:
# Via Typesense API
curl http://localhost:2226/collections \
-H "X-TYPESENSE-API-KEY: your-typesense-api-key-change-in-production" | grep num_documents
# Via Laravel metrics endpoint
curl http://localhost:2222/metricsExpected output:
typesense_collection_documents{collection="vendors"} 226
typesense_collection_documents{collection="listings"} 620
typesense_collection_documents{collection="users"} 2154. Monitoring with Grafana (Optional)
The project includes Prometheus and Grafana for monitoring Typesense metrics:
Services:
- Prometheus: http://localhost:9090
- Grafana: http://localhost:3000 (admin/admin)
- Typesense metrics: http://localhost:2222/metrics
Grafana Dashboard:
- Open http://localhost:3000
- Import dashboard from
grafana/grafana-dashboard-typesense-collections.json - Monitor collection sizes, document counts, and growth over time
Metrics Available:
typesense_collection_documents- Document count per collectiontypesense_stats_search_latency_ms- Search performancetypesense_stats_search_requests_per_second- Request rates
See prometheus.yml for scraping configuration.
Architecture
Components
┌─────────────┐
│ Laravel │
│ Application │
└──────┬──────┘
│
│ Laravel Scout
│
┌──────▼──────┐
│ Typesense │
│ Server │
└─────────────┘Technology Stack
- Typesense 29.0: Search engine running in Docker
- Laravel Scout 10+: Native Typesense support (no third-party drivers needed)
- Docker: Typesense runs as a containerized service
- Network: Typesense accessible at
22.22.1.15:8108(internal Docker network)
Quick Start
Environment Setup
Ensure these environment variables are set in .env:
SCOUT_DRIVER=typesense
SCOUT_QUEUE=true
TYPESENSE_API_KEY=your-typesense-api-key-change-in-production
TYPESENSE_HOST=22.22.1.15
TYPESENSE_PORT=8108
TYPESENSE_PROTOCOL=http
TYPESENSE_CONNECTION_TIMEOUT_SECONDS=2Basic Search Query
use App\Models\Listing;
// Simple search
$results = Listing::search('wedding photographer')->get();
// Search with pagination
$results = Listing::search('wedding photographer')->paginate(15);
// Search with filters
$results = Listing::search('photographer')
->options([
'filter_by' => 'verified:=true && price:>=100'
])
->get();Configuration
See config/scout.php for collection schemas and searchable fields.
Key Configuration Sections
Model Settings: Defines the Typesense schema for each searchable model Search Parameters: Default search behavior (query fields, typo tolerance, etc.) Collection Schema: Field types, sortability, and faceting options
Adding Search to a New Model
Step 1: Add the Searchable Trait
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;
class YourModel extends Model
{
use Searchable;
// ... rest of your model
}Step 2: Define Searchable Data
Implement the toSearchableArray() method to specify what data should be indexed:
public function toSearchableArray(): array
{
// Load any relationships needed for search
$this->loadMissing(['categories', 'tags']);
return [
'id' => (string) $this->id,
'name' => $this->name,
'description' => $this->description ?? '',
'price' => $this->price ? (float) $this->price : 0.0,
'status' => $this->status,
'verified' => (bool) $this->verified,
'categories' => $this->categories->pluck('name')->toArray(),
'created_at' => $this->created_at->timestamp,
];
}Important rules:
- All IDs must be cast to strings
- Required sortable fields MUST have default values (cannot be null)
- Numeric fields must be properly cast to
intorfloat - Boolean fields must be cast to
bool - Arrays should use the
string[]type in schema - Timestamps should be converted to Unix timestamps (integers)
Step 3: Define the Index Name
public function searchableAs(): string
{
return 'your_models'; // Collection name in Typesense
}Step 4: Configure the Typesense Schema
Add your model's schema to config/scout.php under typesense.model-settings:
'model-settings' => [
\App\Models\YourModel::class => [
'collection-schema' => [
'fields' => [
['name' => 'id', 'type' => 'string'],
['name' => 'name', 'type' => 'string', 'sort' => true],
['name' => 'description', 'type' => 'string', 'optional' => true],
['name' => 'price', 'type' => 'float', 'facet' => true, 'sort' => true],
['name' => 'status', 'type' => 'string', 'facet' => true],
['name' => 'verified', 'type' => 'bool', 'facet' => true],
['name' => 'categories', 'type' => 'string[]', 'facet' => true, 'optional' => true],
['name' => 'created_at', 'type' => 'int64', 'sort' => true],
],
'default_sorting_field' => 'created_at',
],
'search-parameters' => [
'query_by' => 'name,description',
'prefix' => 'true,true',
'num_typos' => 2,
'typo_tokens_threshold' => 1,
'drop_tokens_threshold' => 1,
],
],
],Step 5: Import Data
# Flush existing data (if any)
docker exec wedissimo-api php artisan scout:flush "App\Models\YourModel"
# Import all records
docker exec wedissimo-api php artisan scout:import "App\Models\YourModel"Schema Configuration
Field Types
| Type | Description | Example |
|---|---|---|
string | Text field | 'name', 'email' |
int32 | 32-bit integer | 'review_count' |
int64 | 64-bit integer (timestamps) | 'created_at' |
float | Floating point number | 'price', 'rating' |
bool | Boolean | 'verified', 'featured' |
string[] | Array of strings | 'categories', 'tags' |
geopoint | Lat/lng coordinates | 'location_latlng' |
Field Attributes
facet: true: Enables filtering on this field (use for categories, status, boolean flags)sort: true: Enables sorting on this field (required for sortable columns)optional: true: Field may be null/missing in some documents
Critical Rules:
- Fields marked with
sort: trueCANNOT be optional - Sortable fields MUST have default values in
toSearchableArray() - The
default_sorting_fieldmust be a required, sortable field
Example Field Configurations
// Required sortable string
['name' => 'title', 'type' => 'string', 'sort' => true]
// Required sortable numeric (with default value in model)
['name' => 'price', 'type' => 'float', 'facet' => true, 'sort' => true]
// Optional filterable field
['name' => 'description', 'type' => 'string', 'optional' => true]
// Filterable boolean
['name' => 'verified', 'type' => 'bool', 'facet' => true]
// Array field for categories
['name' => 'categories', 'type' => 'string[]', 'facet' => true, 'optional' => true]Search Parameters
Query Fields
The query_by parameter defines which fields are searched:
'search-parameters' => [
'query_by' => 'title,description,vendor_name,location',
'prefix' => 'true,true,true,true',
'num_typos' => 2,
'typo_tokens_threshold' => 1,
'drop_tokens_threshold' => 1,
],Parameters explained:
query_by: Comma-separated list of fields to searchprefix: Enable prefix matching per field (e.g., "wed" matches "wedding")num_typos: Maximum typos allowed (2 = very tolerant)typo_tokens_threshold: Minimum tokens before typo tolerance kicks indrop_tokens_threshold: Minimum tokens before dropping words for better results
Filtering
Use Typesense's native filter syntax in the filter_by option:
$filters = [];
// Boolean filter
$filters[] = 'verified:=true';
// Numeric comparison
$filters[] = 'price:>=100';
$filters[] = 'price:<=500';
// String exact match
$filters[] = 'status:=active';
// Array contains
$filters[] = 'categories:=photography';
// Combine filters
$searchQuery->options([
'filter_by' => implode(' && ', $filters)
]);Filter operators:
:=- Equals:!=- Not equals:>- Greater than:>=- Greater than or equal:<- Less than:<=- Less than or equal
Sorting
// Sort by a single field
$results = Listing::search('photographer')
->orderBy('price', 'asc')
->get();
// Sort with filters
$results = Listing::search('photographer')
->options(['filter_by' => 'verified:=true'])
->orderBy('rating', 'desc')
->get();Geographic Search with PostGIS
Overview
Wedissimo uses PostGIS (PostgreSQL spatial extension) for accurate circular geographic searches. Listings have a service_center point and service_radius_miles to define their coverage area.
Architecture
Hybrid Search Strategy:
- Text Query Present: Use Typesense first for relevance-based search, then filter by PostGIS
- Geographic-Only Query: Use PostGIS first for accurate counts and full result access
- Fallback: Use Typesense for general browsing without geography
This ensures:
- Accurate total counts for pagination
- All results accessible (not limited to Typesense's 250-per-page limit)
- Fast geographic calculations using spatial indexes
Search Types
1. Point-in-Circle Search (Lat/Lng)
Find listings whose service area covers a specific point:
GET /api/v1/listings/search?latitude=51.3883&longitude=0.5045&categories=Wedding PhotographersLogic: Returns listings where distance(listing.service_center, search_point) <= listing.service_radius_miles
2. Region Overlap Search (Google Places)
Find listings whose service area overlaps with a region:
GET /api/v1/listings/search?google_place_id=ChIJMXF6bSrJ2EcR9GPlgxb8fxw&categories=Wedding PhotographersLogic:
- Google Places API returns viewport bounds
- Convert to center point + radius using Haversine formula
- Returns listings where
distance(listing.service_center, region_center) <= listing.service_radius + region_radius
Service Center Column
Listings have a PostGIS geography column that auto-syncs from latitude/longitude:
// In Listing model - auto-synced on save
protected static function booted(): void
{
static::saving(function (Listing $listing) {
if ($listing->latitude && $listing->longitude) {
$listing->service_center = new Point(
$listing->latitude,
$listing->longitude
);
}
});
}Spatial Index: A GIST index on service_center enables fast geographic queries even with thousands of listings.
PostGIS Query Examples
use Modules\Listing\Models\Listing;
// Point-in-circle: Find listings covering a specific location
$listings = Listing::whereRaw(
'ST_DWithin(
service_center::geography,
ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography,
service_radius_miles * 1609.34
)',
[$longitude, $latitude]
)->get();
// Region overlap: Any overlap with a region
$listings = Listing::whereRaw(
'ST_DWithin(
service_center::geography,
ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography,
(service_radius_miles::float + ?) * 1609.34
)',
[$regionLng, $regionLat, $regionRadiusMiles]
)->get();Notes:
ST_DWithinuses meters, so miles are converted:miles * 1609.34SRID 4326is WGS84 (standard lat/lng coordinate system)geographytype uses Earth's curvature for accurate distance::floatcasting handles PostgreSQL integer + float operations
Migrations
// 1. Enable PostGIS extension
DB::statement('CREATE EXTENSION IF NOT EXISTS postgis');
// 2. Add service_center column
DB::statement("
ALTER TABLE listings
ADD COLUMN service_center geography(Point, 4326)
");
// 3. Backfill from existing data
DB::statement("
UPDATE listings
SET service_center = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)
WHERE latitude IS NOT NULL AND longitude IS NOT NULL
");
// 4. Create spatial index
DB::statement('
CREATE INDEX listings_service_center_gist_idx
ON listings USING GIST (service_center)
');Testing Geographic Search
Tests use PostgreSQL (not SQLite) to validate PostGIS functionality:
// phpunit.xml - uses PostgreSQL
<env name="DB_CONNECTION" value="pgsql"/>
<env name="DB_DATABASE" value="wedissimo_test"/>
// Test example
it('filters listings outside service area correctly', function () {
$listing = Listing::factory()->create([
'latitude' => 51.5074, // London
'longitude' => -0.1278,
'service_radius_miles' => 10,
]);
// Search in Rochester (50+ miles away)
$response = $this->getJson(
'/api/v1/listings/search?latitude=51.3883&longitude=0.5045'
);
expect($response->json('meta.total'))->toBe(0);
});See modules/Listing/Tests/Feature/ListingPublicApiTest.php for complete test coverage.
Performance
Circular vs Rectangular:
- PostGIS circular searches are 41% more accurate than bounding box approaches
- GIST spatial indexes make geographic queries fast even with large datasets
- No Typesense 250-result limit - all matching listings accessible via pagination
Benchmarks (with 1000 listings):
- Geographic search: ~50ms
- Geographic + category filter: ~75ms
- Geographic + multiple filters: ~100ms
API Endpoints
Listings Search
GET /api/v1/listings/search
Query parameters:
query: search term (searches title, description, vendor_name, location, based_in)google_place_id: Google Places ID for region searchlatitude,longitude: point coordinates for circle searchcategories: comma-separated category names (listing service types)tags: comma-separated tag namesverified: true/falsefeatured: true/falsestatus: listing statusmin_price,max_price: price rangeorder_by,order_dir: sorting optionsper_page: results per page (max 100)
Geographic Search Examples:
# Point-in-circle: Find listings covering Rochester, Kent
curl "http://localhost:2222/api/v1/listings/search?latitude=51.3883&longitude=0.5045&categories=Wedding Photographers"
# Region overlap: Find listings overlapping with a Google Places region
curl "http://localhost:2222/api/v1/listings/search?google_place_id=ChIJMXF6bSrJ2EcR9GPlgxb8fxw&categories=Wedding Photographers"
# Add filters to geographic search
curl "http://localhost:2222/api/v1/listings/search?latitude=51.3883&longitude=0.5045&categories=Wedding Photographers&min_price=1000&max_price=3000&verified=true"Text Search Example:
# Text search with Typesense (uses relevance ranking)
curl "http://localhost:2222/api/v1/listings/search?query=photography&verified=true&min_price=100&per_page=15"Search Flow:
- Has text query? → Use Typesense for relevance, then PostGIS filter
- Has geography? → Use PostGIS first for accurate counts
- Neither? → Use Typesense for general browsing
Implementation: See Listing domain documentation for details on the Listing controller (app/Http/Controllers/v1/ListingController.php).
Vendors Search
GET /api/v1/vendors/search
Query parameters:
query: search term (searches name, business_name, description, location, city)categories: comma-separated category namesverified: true/falsestatus,approval_status: filteringmin_years_experience: minimum yearslatitude,longitude,radius_km: geo-searchorder_by,order_dir: sorting
Example:
curl "http://localhost:2222/api/v1/vendors/search?query=london&verified=true&per_page=15"Implementation: See Vendor domain documentation for details on the Vendor module's search controller (modules/Vendor/Http/Controllers/VendorController.php).
Building Search Interfaces
Livewire Search Pages
/admin/search/listings- Interactive listing search (modules/Listing/Livewire/ListingSearch.php)/admin/search/vendors- Interactive vendor search (modules/Vendor/Livewire/VendorSearch.php)
Both pages feature:
- Real-time search with debouncing
- Filters (price, verified, location, etc.)
- Sorting options
- Pagination
- Query string persistence
Important: Admin search pages use PostgreSQL queries (not Typesense) to ensure all records are visible regardless of Typesense indexing status. This provides reliability for admin users who need to see all data, even if the search index is out of sync.
Public-facing search endpoints use Typesense for fast, typo-tolerant search with relevance ranking.
For implementation details, see:
Livewire Component Example (Public Search with Typesense)
<?php
namespace App\Livewire;
use App\Models\YourModel;
use Livewire\Component;
use Livewire\WithPagination;
class YourModelSearch extends Component
{
use WithPagination;
public string $query = '';
public ?bool $verified = null;
public ?float $minPrice = null;
public ?float $maxPrice = null;
public string $sortBy = 'created_at';
public string $orderBy = 'desc';
protected $queryString = [
'query' => ['except' => ''],
'verified',
'minPrice',
'maxPrice',
'sortBy' => ['except' => 'created_at'],
'orderBy' => ['except' => 'desc'],
];
public function updatingQuery(): void
{
$this->resetPage();
}
public function sortColumn(string $field): void
{
if ($this->sortBy === $field) {
$this->orderBy = $this->orderBy === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $field;
$this->orderBy = 'asc';
}
$this->resetPage();
}
public function render(): \Illuminate\Contracts\View\View
{
$searchQuery = YourModel::search($this->query);
// Build filters
$filters = [];
if ($this->verified !== null) {
$filters[] = 'verified:=' . ($this->verified ? 'true' : 'false');
}
if ($this->minPrice !== null && $this->minPrice !== '' && is_numeric($this->minPrice)) {
$filters[] = 'price:>=' . (float) $this->minPrice;
}
if ($this->maxPrice !== null && $this->maxPrice !== '' && is_numeric($this->maxPrice)) {
$filters[] = 'price:<=' . (float) $this->maxPrice;
}
// Apply filters
if (!empty($filters)) {
$searchQuery->options(['filter_by' => implode(' && ', $filters)]);
}
// Apply sorting
$searchQuery->orderBy($this->sortBy, $this->orderBy);
return view('livewire.your-model-search', [
'items' => $searchQuery->paginate(15),
]);
}
}Admin Search Component Example (PostgreSQL via Service Layer)
For admin interfaces, use the service layer pattern with PostgreSQL queries:
<?php
namespace App\Livewire;
use App\Services\YourModelService;
use Livewire\Component;
use Livewire\WithPagination;
use Illuminate\Contracts\View\View;
class YourModelAdminSearch extends Component
{
use WithPagination;
public string $query = '';
public ?bool $verified = null;
public ?float $minPrice = null;
public ?float $maxPrice = null;
public string $sortBy = 'created_at';
public string $orderBy = 'desc';
protected $queryString = [
'query' => ['except' => ''],
'verified',
'minPrice',
'maxPrice',
'sortBy' => ['except' => 'created_at'],
'orderBy' => ['except' => 'desc'],
];
public function updatingQuery(): void
{
$this->resetPage();
}
public function sortColumn(string $field): void
{
if ($this->sortBy === $field) {
$this->orderBy = $this->orderBy === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $field;
$this->orderBy = 'asc';
}
$this->resetPage();
}
public function render(): View
{
$service = app(YourModelService::class);
$filters = [
'query' => $this->query,
'verified' => $this->verified,
'min_price' => $this->minPrice,
'max_price' => $this->maxPrice,
'sort_by' => $this->sortBy,
'order_by' => $this->orderBy,
];
$items = $service->adminSearch($filters, 15);
return view('livewire.your-model-admin-search', [
'items' => $items,
]);
}
}Key Differences:
- Admin components use service layer methods (
adminSearch()) - Service layer uses PostgreSQL queries with
ilikefor text search - Ensures all records are visible regardless of Typesense indexing
- Public components use Typesense directly for fast, typo-tolerant search
Blade View with Sortable Headers
<table class="min-w-full divide-y divide-gray-300 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th wire:click="sortColumn('name')"
class="cursor-pointer px-6 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
Name
@if($sortBy === 'name')
<flux:icon name="{{ $orderBy === 'asc' ? 'chevron-up' : 'chevron-down' }}" class="w-4 h-4 inline ml-1" />
@endif
</th>
<!-- More columns -->
</tr>
</thead>
<tbody>
@foreach($items as $item)
<tr>
<td>{{ $item->name }}</td>
</tr>
@endforeach
</tbody>
</table>Best Practices
1. Type Casting
Always explicitly cast values in toSearchableArray():
// Good
'price' => $this->price ? (float) $this->price : 0.0,
'count' => (int) $this->count,
'verified' => (bool) $this->verified,
// Bad
'price' => $this->price, // May be null or string2. Default Values for Sortable Fields
Sortable fields must never be null:
// Good - has default
'rating' => $this->rating ? (float) $this->rating : 0.0,
'years_of_experience' => $this->years_of_experience ? (int) $this->years_of_experience : 0,
// Bad - can be null
'rating' => $this->rating ? (float) $this->rating : null,3. Filter Validation
Always validate filter inputs before building filter strings:
// Good
if ($this->minPrice !== null && $this->minPrice !== '' && is_numeric($this->minPrice)) {
$filters[] = 'price:>=' . (float) $this->minPrice;
}
// Bad - no validation
$filters[] = 'price:>=' . $this->minPrice; // May cause errors4. Eager Loading
Load relationships in toSearchableArray() to avoid N+1 queries:
public function toSearchableArray(): array
{
// Load relationships once
$this->loadMissing(['categories', 'tags', 'vendor']);
return [
'categories' => $this->categories->pluck('name')->toArray(),
'vendor_name' => $this->vendor?->name ?? '',
];
}5. Index Naming
Use plural snake_case for collection names:
public function searchableAs(): string
{
return 'listings'; // Good
// return 'Listing'; // Bad
// return 'listing'; // Acceptable but plural preferred
}6. Avoid Method Name Collisions
Don't name component methods the same as properties:
// Bad - collision with $sortBy property
public function sortBy(string $field): void { }
// Good - no collision
public function sortColumn(string $field): void { }7. Queue Sync Operations
Use SCOUT_QUEUE=true to queue sync operations for better performance. Make sure queue workers are running:
docker exec wedissimo-api php artisan queue:workCommon Issues
Issue: "Could not find a field named field_name in the schema for sorting"
Cause: Field doesn't have 'sort' => true or is optional.
Solution:
- Add
'sort' => trueto the field in schema - Make field non-optional (remove
'optional' => true) - Ensure default value in
toSearchableArray() - Flush and reimport data
docker exec wedissimo-api php artisan scout:flush "App\Models\YourModel"
docker exec wedissimo-api php artisan scout:import "App\Models\YourModel"Issue: "Field field_name must be a string/int/float"
Cause: Wrong type being sent to Typesense (often null or uncast value).
Solution: Add proper type casting and default values in toSearchableArray():
// Before (wrong)
'price' => $this->price,
// After (correct)
'price' => $this->price ? (float) $this->price : 0.0,Issue: "$wire.sortBy is not a function" JavaScript error
Cause: Method name collision with Livewire property.
Solution: Rename the method:
// Before (wrong)
public string $sortBy = 'name';
public function sortBy(string $field): void { }
// After (correct)
public string $sortBy = 'name';
public function sortColumn(string $field): void { }Issue: Case-sensitive search results
Cause: Using database queries instead of Scout/Typesense.
Solution: Ensure you're using Scout's search() method:
// Correct - case insensitive
User::search($query)->paginate();
// Wrong - case sensitive
User::where('name', 'LIKE', "%{$query}%")->paginate();Issue: Search not updating after model changes
Cause: Scout observer not updating index.
Solution:
- Check
SCOUT_QUEUEis set - If queue is enabled, ensure queue worker is running
- Manually reimport if needed:
docker exec wedissimo-api php artisan scout:import "App\Models\YourModel"Reference Examples
Complete Model Examples
Core Model: See app/Models/Listing.php for a complete implementation:
- Line 132-158:
toSearchableArray()method - Line 160-163:
searchableAs()method
Module Model: See modules/Vendor/Models/Vendor.php for a module-based searchable model demonstrating the same patterns within a modular structure.
Complete Schema Examples
Listing Schema: See config/scout.php lines 184-218 for the Listing schema configuration.
Vendor Schema: Module schemas can be configured in modules/Vendor/Config/scout.php and merged via the ServiceProvider (see Vendor module for example).
Complete Livewire Component Examples
Listing Search (Core): See app/Livewire/ListingSearch.php for a full search component with:
- Search input
- Multiple filters
- Sortable columns
- Pagination
Vendor Search (Module): See modules/Vendor/Livewire/VendorSearch.php for a modular search component demonstrating:
- Module-based Livewire component structure
- Permission checks in
mount()method - View namespace usage (
vendor::livewire.vendor-search)
Complete View Examples
Listing Search View: resources/views/livewire/listing-search.blade.php
Vendor Search View: modules/Vendor/Resources/views/livewire/vendor-search.blade.php
Both include:
- Filter UI
- Sortable table headers with icons
- Dark mode support
Commands
Reindex Command (Recommended)
The typesense:reindex command is the easiest way to reindex your search collections:
# Reindex all models (interactive prompt)
docker exec wedissimo-api php artisan typesense:reindex
# Reindex all models (non-interactive)
docker exec wedissimo-api php artisan typesense:reindex --all
# Reindex all models (skip confirmation - for Cloud Run jobs)
docker exec wedissimo-api php artisan typesense:reindex --all --force
# Reindex specific model
docker exec wedissimo-api php artisan typesense:reindex "App\Models\Listing"
# Only flush without reimporting
docker exec wedissimo-api php artisan typesense:reindex --all --flush-only
# Only import without flushing
docker exec wedissimo-api php artisan typesense:reindex --all --import-onlyFeatures:
- Interactive model selection
- Progress tracking with durations
- Error handling and summary report
- Options for flush-only or import-only operations
--forceflag to skip confirmation (for non-interactive environments like Cloud Run jobs)
Scout Commands (Low-level)
For manual operations on individual models:
# Flush all records from index
docker exec wedissimo-api php artisan scout:flush "App\Models\YourModel"
# Import all records to index
docker exec wedissimo-api php artisan scout:import "App\Models\YourModel"Typesense API (Direct)
Check Typesense collections and schemas directly:
# List all collections
curl http://22.22.1.15:8108/collections \
-H "X-TYPESENSE-API-KEY: your-typesense-api-key-change-in-production"
# Check collection schema
curl http://22.22.1.15:8108/collections/listings \
-H "X-TYPESENSE-API-KEY: your-typesense-api-key-change-in-production"
# Search directly (for debugging)
curl "http://22.22.1.15:8108/collections/listings/documents/search?q=photographer&query_by=title,description" \
-H "X-TYPESENSE-API-KEY: your-typesense-api-key-change-in-production"