Jonathan Bird Web Development

Migrating from Algolia to Typesense: Laravel & Vue InstantSearch Guide

Last updated: December 1, 2025

I've always loved using Algolia for its InstantSearch, ease of use and integration. My clients love the functionality such as synonyms, stop words, AI recommendations, merchandising, and the InstantSearch frontend. It's been a reliable part of my stack for years.

However, in early 2025, Algolia decided to change their pricing model. New customers who would previously have been paying around $6K annually were now looking at ~2.5x that amount. For my client who is a B2B e-commerce platform with over 15K products and 1.6M search events per month, this was a substantial increase I couldn't ignore.

Evaluating the Alternatives

This pricing change led me to evaluate all the major alternatives:

  • Meilisearch - Great option with InstantSearch adapter and Laravel support, but lacks native support for rules (promoting, pinning, or boosting documents)
  • Elasticsearch - Powerful but complex, overkill for this project's needs
  • Typesense - Clear winner with its competitive pricing and direct adapter for Algolia InstantSearch and seamless Laravel Scout integration

Typesense stood out for several reasons:

  1. Drop-in InstantSearch compatibility via the typesense-instantsearch-adapter
  2. First-class Laravel Scout support with the official Typesense driver
  3. Significant cost savings - Typesense Cloud with high availability (3 production servers) is roughly 1/3 the cost of Algolia, including server costs and bandwidth
  4. Self-hosting option - You can save even more by running it yourself, though I opted for the fully managed Typesense Cloud service

A Word of Warning: Typesense Cloud UI Limitations

Before diving in, be aware that Typesense Cloud's dashboard is not as feature-rich as Algolia's. Key limitations:

  • No import/export for Curation rules - You can't bulk import query rules (called "Curation" in Typesense) through the UI. Migration requires using their API directly.
  • Limited Curation options - The UI only supports pinning and hiding specific documents. Advanced features like query-time brand/category boosting (Algolia's optionalFilters) aren't available in the UI - you'll need to use the API with sort_by overrides or build a custom admin panel.
  • No visual query rule builder - Algolia's drag-and-drop rule builder doesn't exist in Typesense.

If your team heavily relies on Algolia's merchandising UI to manage search rules, factor in the time to either build a custom admin interface or accept that some features will require API/config management.

The Setup: By the Numbers

For context, here's what I was working with:

  • ~15,000 products in the search index
  • Average record size: ~3.5KB per document
  • Total index size: ~55MB
  • Monthly searches: ~160,000 (aggregated - as InstantSearch is search-as-you-type, the real search events are closer to 16M)
  • Bandwidth calculation: I estimated bandwidth based on average record size × expected results per query × monthly searches

Typesense Cloud's pricing is straightforward - you pay for the cluster size and bandwidth, making it easy to predict costs.

The Migration Process

1. Laravel Scout Configuration

Follow the Laravel Scout Typesense documentation for the initial setup. Install the required packages:

1composer require typesense/typesense-php

Update your .env file with Typesense credentials:

1SCOUT_DRIVER=typesense
2TYPESENSE_API_KEY=your-admin-api-key
3TYPESENSE_API_KEY_SEARCH=your-search-only-api-key
4TYPESENSE_HOST=your-cluster.a1.typesense.net
5TYPESENSE_PORT=443
6TYPESENSE_PROTOCOL=https

2. Define Your Schema

The biggest difference from Algolia is that Typesense requires a predefined schema. While this requires more upfront work, it's not a significant hurdle - you likely already have this structure defined in your database migrations.

In config/scout.php, define your collection schema:

1<?php
2 
3// ... rest of the array
4 
5'typesense' => [
6 'client-settings' => [
7 'api_key' => env('TYPESENSE_API_KEY'),
8 'search_api_key' => env('TYPESENSE_API_KEY_SEARCH'),
9 'nodes' => [
10 [
11 'host' => env('TYPESENSE_HOST'),
12 'port' => env('TYPESENSE_PORT', '443'),
13 'protocol' => env('TYPESENSE_PROTOCOL', 'https'),
14 ],
15 ],
16 ],
17 'model-settings' => [
18 \App\Models\Product::class => [
19 'collection-schema' => [
20 'fields' => [
21 // Basic fields
22 [
23 'name' => 'id',
24 'type' => 'string',
25 ],
26 [
27 'name' => 'sku',
28 'type' => 'string',
29 ],
30 [
31 'name' => 'slug',
32 'type' => 'string',
33 ],
34 [
35 'name' => 'title',
36 'type' => 'string',
37 ],
38 
39 // Optional text fields
40 [
41 'name' => 'description',
42 'type' => 'string',
43 'optional' => true,
44 ],
45 [
46 'name' => 'meta_description',
47 'type' => 'string',
48 'optional' => true,
49 ],
50 
51 // Faceted fields for filtering
52 [
53 'name' => 'brand',
54 'type' => 'string',
55 'facet' => true,
56 ],
57 [
58 'name' => 'categories',
59 'type' => 'object',
60 'facet' => true,
61 ],
62 
63 // Numeric fields for sorting/filtering
64 [
65 'name' => 'price',
66 'type' => 'float',
67 'facet' => true,
68 'sort' => true,
69 ],
70 [
71 'name' => 'popularity',
72 'type' => 'int32',
73 'sort' => true,
74 ],
75 
76 // Boolean flags
77 [
78 'name' => 'in_stock',
79 'type' => 'bool',
80 ],
81 [
82 'name' => 'is_featured',
83 'type' => 'bool',
84 ],
85 
86 // Arrays
87 [
88 'name' => 'tags',
89 'type' => 'string[]',
90 'facet' => true,
91 ],
92 
93 // Required: created_at as UNIX timestamp
94 [
95 'name' => 'created_at',
96 'type' => 'int64',
97 ],
98 ],
99 'enable_nested_fields' => true,
100 ],
101 'search-parameters' => (function () {
102 // Define searchable fields with their relevance weights
103 $fields = [
104 'title' => 4, // Most important
105 'sku' => 3, // Product codes often searched directly
106 'brand' => 2, // Brand searches common
107 'description' => 1, // Least weight but still searchable
108 'tags' => 1,
109 ];
110 
111 return [
112 'query_by' => implode(',', array_keys($fields)),
113 'query_by_weights' => implode(',', array_values($fields)),
114 'sort_by' => '_text_match:desc,popularity:desc',
115 ];
116 })(),
117 ],
118 ],
119],

Pro tip: Define your searchable fields and their weights together in an associative array. This keeps them in sync automatically - no more counting comma-separated values to ensure they match!

3. Update Your Model's toSearchableArray

Your model's toSearchableArray() method defines what gets indexed. Make sure it matches your schema:

1<?php
2// app/Models/Product.php
3 
4public function toSearchableArray(): array
5{
6 return [
7 // Required: ID must be cast to string
8 'id' => (string) $this->id,
9 
10 // Required: created_at must be a UNIX timestamp
11 'created_at' => $this->created_at->timestamp,
12 
13 // Basic fields
14 'sku' => $this->sku,
15 'slug' => $this->slug,
16 'title' => $this->title,
17 'description' => $this->description ?? '',
18 'meta_description' => $this->meta_description ?? '',
19 'brand' => $this->brand?->name ?? '',
20 
21 // Numeric fields
22 'price' => (float) $this->price,
23 'popularity' => (int) $this->sales_count,
24 
25 // Boolean fields
26 'in_stock' => $this->stock_quantity > 0,
27 'is_featured' => (bool) $this->is_featured,
28 
29 // Array fields
30 'tags' => $this->tags->pluck('name')->toArray(),
31 
32 // Nested object for hierarchical categories (for InstantSearch's hierarchical menu)
33 // Build dynamically based on actual depth - only include levels that exist
34 'categories' => $this->buildCategoryHierarchy(),
35 
36 // Pre-computed URLs for display
37 'image_url' => $this->getPublicImageUrl(),
38 'product_url' => route('products.show', $this->slug),
39 ];
40}
41 
42/**
43 * Build hierarchical category structure for InstantSearch.
44 *
45 * Format required by ais-hierarchical-menu widget:
46 * "lvl0": ["Books"],
47 * "lvl1": ["Books > Science Fiction"],
48 * "lvl2": ["Books > Science Fiction > Time Travel"]
49 */
50private function buildCategoryHierarchy(): array
51{
52 $category = $this->category;
53 
54 if (!$category) {
55 return [];
56 }
57 
58 // Build array of category names from root to leaf
59 $ancestors = [];
60 $current = $category;
61 
62 while ($current) {
63 array_unshift($ancestors, $current->name);
64 $current = $current->parent;
65 }
66 
67 // Build hierarchical levels
68 $hierarchy = [];
69 $path = [];
70 
71 foreach ($ancestors as $index => $name) {
72 $path[] = $name;
73 $hierarchy['lvl' . $index][] = implode(' > ', $path);
74 }
75 
76 return $hierarchy;
77}

4. Set Up Typesense Cloud

  1. Create a cluster at cloud.typesense.org
  2. Choose your region and cluster size based on your data volume
  3. Note your API keys:
    • Admin API Key: For indexing operations (keep server-side only)
    • Search-only API Key: For frontend search requests (safe to expose)

5. Import Your Data

Flush and re-import via Laravel Scout:

1php artisan scout:flush "App\Models\Product"
2php artisan scout:import "App\Models\Product"

For large datasets, you may want to chunk the import:

1php artisan scout:import "App\Models\Product" --chunk=500

6. Update Your Vue InstantSearch Frontend

Install the Typesense InstantSearch adapter:

1npm install --save typesense-instantsearch-adapter @babel/runtime

Pass Configuration from Laravel to Frontend

In your Blade template, expose the Typesense configuration:

1{{-- resources/views/search.blade.php --}}
2 
3@push('before-scripts')
4<script>
5 window.typesense = {
6 search_api_key: '{{ config('scout.typesense.client-settings.search_api_key') }}',
7 nodes: @json(config('scout.typesense.client-settings.nodes')),
8 query_by: '{{ config('scout.typesense.model-settings.' . \App\Models\Product::class . '.search-parameters.query_by') }}',
9 query_by_weights: '{{ config('scout.typesense.model-settings.' . \App\Models\Product::class . '.search-parameters.query_by_weights') }}',
10 sort_by: '{{ config('scout.typesense.model-settings.' . \App\Models\Product::class . '.search-parameters.sort_by') }}',
11 };
12 window.index = '{{ \App\Models\Product::class }}';
13</script>
14@endpush

Update Your Vue Component

Here's a complete example of the InstantSearch component migration:

1<template>
2 <ais-instant-search
3 :search-client="searchClient"
4 :index-name="indexName"
5 :insights="false"
6 >
7 <div class="search-layout">
8 <!-- Search Box -->
9 <ais-search-box placeholder="Search products..." />
10 
11 <!-- Filters Sidebar -->
12 <aside class="filters">
13 <h3>Brand</h3>
14 <ais-refinement-list attribute="brand" :limit="10" />
15 
16 <h3>Categories</h3>
17 <ais-hierarchical-menu
18 :attributes="[
19 'categories.lvl0',
20 'categories.lvl1',
21 'categories.lvl2',
22 ]"
23 />
24 
25 <h3>Price Range</h3>
26 <ais-range-input attribute="price" />
27 
28 <h3>Availability</h3>
29 <ais-toggle-refinement
30 attribute="in_stock"
31 label="In Stock Only"
32 />
33 </aside>
34 
35 <!-- Results -->
36 <main class="results">
37 <!-- Sort Options -->
38 <ais-sort-by :items="sortOptions" />
39 
40 <!-- Stats -->
41 <ais-stats />
42 
43 <!-- Product Grid -->
44 <ais-hits>
45 <template v-slot:item="{ item }">
46 <article class="product-card">
47 <a :href="item.product_url">
48 <img :src="item.image_url" :alt="item.title" />
49 <h2>{{ item.title }}</h2>
50 <p class="brand">{{ item.brand }}</p>
51 <p class="price">${{ item.price.toFixed(2) }}</p>
52 <span v-if="!item.in_stock" class="out-of-stock">
53 Out of Stock
54 </span>
55 </a>
56 </article>
57 </template>
58 </ais-hits>
59 
60 <!-- Pagination -->
61 <ais-pagination />
62 </main>
63 </div>
64 </ais-instant-search>
65</template>
66 
67<script>
68import TypesenseInstantSearchAdapter from 'typesense-instantsearch-adapter';
69import {
70 AisInstantSearch,
71 AisSearchBox,
72 AisHits,
73 AisPagination,
74 AisRefinementList,
75 AisHierarchicalMenu,
76 AisRangeInput,
77 AisToggleRefinement,
78 AisSortBy,
79 AisStats,
80} from 'vue-instantsearch/vue3/es';
81 
82export default {
83 components: {
84 AisInstantSearch,
85 AisSearchBox,
86 AisHits,
87 AisPagination,
88 AisRefinementList,
89 AisHierarchicalMenu,
90 AisRangeInput,
91 AisToggleRefinement,
92 AisSortBy,
93 AisStats,
94 },
95 
96 data() {
97 const indexName = window.index;
98 
99 // Initialize Typesense adapter
100 const typesenseAdapter = new TypesenseInstantSearchAdapter({
101 server: {
102 apiKey: window.typesense.search_api_key,
103 nodes: window.typesense.nodes,
104 cacheSearchResultsForSeconds: 120,
105 },
106 additionalSearchParameters: {
107 query_by: window.typesense.query_by,
108 query_by_weights: window.typesense.query_by_weights,
109 sort_by: window.typesense.sort_by,
110 },
111 });
112 
113 return {
114 searchClient: typesenseAdapter.searchClient,
115 indexName: indexName,
116 
117 // Sort options use different format than Algolia
118 sortOptions: [
119 { value: indexName, label: 'Relevance' },
120 { value: `${indexName}/sort/price:asc`, label: 'Price: Low to High' },
121 { value: `${indexName}/sort/price:desc`, label: 'Price: High to Low' },
122 { value: `${indexName}/sort/popularity:desc`, label: 'Most Popular' },
123 ],
124 };
125 },
126};
127</script>

Key Differences and Gotchas

Sort-by Widget Format

This is one of the biggest syntax changes. Algolia uses replica indices, while Typesense uses a path-based format:

1// Algolia format - requires separate replica indices
2const sortOptions = [
3 { value: 'products', label: 'Relevance' },
4 { value: 'products_price_asc', label: 'Price: Low to High' },
5 { value: 'products_price_desc', label: 'Price: High to Low' },
6];
7 
8// Typesense format - no replicas needed!
9const sortOptions = [
10 { value: 'products', label: 'Relevance' },
11 { value: 'products/sort/price:asc', label: 'Price: Low to High' },
12 { value: 'products/sort/price:desc', label: 'Price: High to Low' },
13];

Bonus: Typesense doesn't require maintaining separate replica indices for each sort order, which also saves on costs and complexity.

Filter Syntax in Backend Queries

If you're using Laravel Scout for backend searches, the filter syntax differs:

1// Algolia syntax
2$results = Product::search($query)
3 ->where('is_featured', true)
4 ->where('brand', 'Apple')
5 ->get();
6 
7// Typesense syntax
8$results = Product::search($query)
9 ->options([
10 'filter_by' => 'is_featured:=true && brand:=Apple'
11 ])
12 ->get();

Here's a more complex example with OR conditions:

1// Filtering for exclusive products OR products available to specific customers
2$filterBy = 'is_exclusive:=false';
3 
4if ($allowedCustomerCodes->isNotEmpty()) {
5 $customerFilters = $allowedCustomerCodes
6 ->map(fn($code) => "customer_codes:={$code}")
7 ->implode(' || ');
8 
9 $filterBy .= ' || ' . $customerFilters;
10}
11 
12$results = Product::search($query)
13 ->options(['filter_by' => $filterBy])
14 ->take(20)
15 ->get();

Field Weights

Algolia allows inline weights, but Typesense requires a separate query_by_weights parameter:

1// This WON'T work in Typesense
2'query_by' => 'title:4,sku:3,brand:2,description:1'
3 
4// This is the correct approach
5'query_by' => 'title,sku,brand,description',
6'query_by_weights' => '4,3,2,1',

Maximum Sort Fields

Typesense limits sorting to 3 fields maximum. Choose your most important ranking factors:

1// This will error - too many sort fields
2'sort_by' => '_text_match:desc,popularity:desc,price:asc,created_at:desc'
3 
4// Pick your top 3
5'sort_by' => '_text_match:desc,popularity:desc,price:asc'

Analytics/Insights Not Supported

Algolia's analytics widgets aren't supported by the Typesense adapter. Make sure to disable them:

1<!-- This will cause errors with Typesense -->
2<ais-instant-search :insights="true">
3 
4<!-- Disable insights -->
5<ais-instant-search :insights="false">

Nested Fields

For hierarchical data like categories, enable nested fields in your schema:

1'collection-schema' => [
2 'fields' => [
3 [
4 'name' => 'categories',
5 'type' => 'object',
6 'facet' => true,
7 ],
8 ],
9 'enable_nested_fields' => true, // Required for nested objects
10],

Handling User-Specific Data

One challenge specific to my client's B2B platform was displaying user-specific information like custom pricing and wishlist status. With Algolia, you might be tempted to bake this into the index, but that doesn't scale when you have personalised pricing per customer.

My solution: fetch search results from Typesense for fast, relevant matching, then enrich the results with user-specific data from the backend.

The Search Controller

1// app/Http/Controllers/SearchController.php
2 
3public function search(Request $request): JsonResponse
4{
5 $query = $request->input('q', '');
6 $customer = auth()->user()?->customer;
7 
8 // Get base results from Typesense
9 $products = Product::search($query)
10 ->options(['filter_by' => $this->buildFilterString($customer)])
11 ->take(20)
12 ->get();
13 
14 // Enrich with user-specific data
15 $enrichedResults = $products->map(function ($product) use ($customer) {
16 return [
17 'id' => $product->id,
18 'title' => $product->title,
19 'slug' => $product->slug,
20 'image_url' => $product->image_url,
21 
22 // User-specific pricing
23 'price' => $this->priceCalculator->getCustomerPrice($product, $customer),
24 'original_price' => $product->price,
25 'has_discount' => $this->priceCalculator->hasDiscount($product, $customer),
26 
27 // User-specific wishlist status
28 'in_wishlist' => $customer
29 ? $customer->wishlists()->whereHas('items', fn($q) => $q->where('product_id', $product->id))->exists()
30 : false,
31 
32 // Stock for customer's location
33 'stock_status' => $this->stockService->getStatusForCustomer($product, $customer),
34 ];
35 });
36 
37 return response()->json(['results' => $enrichedResults]);
38}
39 
40private function buildFilterString(?Customer $customer): string
41{
42 $filters = ['is_active:=true'];
43 
44 // Exclude products not available to this customer
45 if ($customer) {
46 $filters[] = "(is_exclusive:=false || customer_codes:={$customer->code})";
47 } else {
48 $filters[] = 'is_exclusive:=false';
49 }
50 
51 return implode(' && ', $filters);
52}

Yes, this adds an extra processing step, but it's necessary for this use case where:

  • Each customer can have unique negotiated pricing
  • Wishlist status needs to be accurate per-user
  • Stock availability varies by customer location

This hybrid approach gives me the best of both worlds - Typesense's lightning-fast search with the application's business logic applied on top.

Migrating Synonyms from Algolia to Typesense Format

Algolia's synonym export is JSON format, while Typesense uses CSV for bulk imports. There is no online converter or code online so I wanted to share a standalone PHP script that you can use to convert them that worked for me.

Here's an Artisan command to convert between formats:

1<?php
2// app/Console/Commands/ConvertAlgoliaSynonyms.php
3 
4namespace App\Console\Commands;
5 
6use Illuminate\Console\Command;
7 
8class ConvertAlgoliaSynonyms extends Command
9{
10 protected $signature = 'typesense:convert-synonyms
11 {input : Path to Algolia synonyms JSON file}
12 {output : Path for output CSV file}';
13 
14 protected $description = 'Convert Algolia synonyms JSON to Typesense CSV format';
15 
16 public function handle(): int
17 {
18 $inputFile = $this->argument('input');
19 $outputFile = $this->argument('output');
20 
21 if (!file_exists($inputFile)) {
22 $this->error("File not found: {$inputFile}");
23 return self::FAILURE;
24 }
25 
26 $json = file_get_contents($inputFile);
27 $json = preg_replace('/^\xEF\xBB\xBF/', '', $json); // Remove BOM
28 $algoliaSynonyms = json_decode($json, true);
29 
30 if (!$algoliaSynonyms) {
31 $this->error('Failed to parse JSON file');
32 return self::FAILURE;
33 }
34 
35 $fp = fopen($outputFile, 'w');
36 fputcsv($fp, ['id', 'synonyms', 'root', 'delete?', 'locale', 'symbols_to_index']);
37 
38 $converted = 0;
39 
40 foreach ($algoliaSynonyms as $synonym) {
41 $type = $synonym['type'] ?? '';
42 
43 switch ($type) {
44 case 'synonym':
45 // Multi-way: all words are equivalent
46 $words = array_map('trim', $synonym['synonyms'] ?? []);
47 if (count($words) >= 2) {
48 fputcsv($fp, ['', implode(',', $words), '', '', '', '']);
49 $converted++;
50 }
51 break;
52 
53 case 'onewaysynonym':
54 // One-way: input -> synonyms
55 $input = trim($synonym['input'] ?? '');
56 $targets = array_map('trim', $synonym['synonyms'] ?? []);
57 if ($input && count($targets) > 0) {
58 fputcsv($fp, ['', $input, implode(',', $targets), '', '', '']);
59 $converted++;
60 }
61 break;
62 
63 case 'altcorrection1':
64 case 'altcorrection2':
65 // Typo corrections
66 $word = trim($synonym['word'] ?? '');
67 $corrections = array_map('trim', $synonym['corrections'] ?? []);
68 if ($word && count($corrections) > 0) {
69 fputcsv($fp, ['', $word, implode(',', $corrections), '', '', '']);
70 $converted++;
71 }
72 break;
73 }
74 }
75 
76 fclose($fp);
77 
78 $this->info("Converted {$converted} synonyms to {$outputFile}");
79 
80 return self::SUCCESS;
81 }
82}

Run it with:

1php artisan typesense:convert-synonyms storage/algolia_synonyms.json storage/typesense_synonyms.csv

The CSV format for Typesense:

  • Multi-way synonyms: All words in synonyms column, root empty
  • One-way synonyms: Source word in synonyms, target word(s) in root

Migrating Query Rules (Curation) from Algolia

This was the trickiest part of the migration. Algolia's "Query Rules" are called "Curation" or "Overrides" in Typesense, and there's no import feature in the Typesense Cloud UI - you must use the API.

Step 1: Export from Algolia

Export your query rules from Algolia's dashboard as JSON.

Step 2: Convert to Typesense Format

Use the official conversion tool:

1npx algolia-query-rules-to-typesense@latest \
2 --input algolia_rules.json \
3 --output converted_typesense_rules.json

Step 3: Import via API

Since there's no bulk import in the UI, I created an Artisan command to import the rules:

1<?php
2// app/Console/Commands/ImportTypesenseOverrides.php
3 
4namespace App\Console\Commands;
5 
6use App\Models\Product;
7use Illuminate\Console\Command;
8use Illuminate\Support\Facades\Http;
9 
10class ImportTypesenseOverrides extends Command
11{
12 protected $signature = 'typesense:import-overrides
13 {file : Path to the converted rules JSON file}
14 {--collection= : Collection name (defaults to Product searchableAs)}';
15 
16 protected $description = 'Import curation rules (overrides) into Typesense';
17 
18 public function handle(): int
19 {
20 $file = $this->argument('file');
21 $config = config('scout.typesense.client-settings');
22 $collection = $this->option('collection') ?? (new Product())->searchableAs();
23 
24 if (!file_exists($file)) {
25 $this->error("File not found: {$file}");
26 return self::FAILURE;
27 }
28 
29 $rules = json_decode(file_get_contents($file), true);
30 
31 if (!$rules) {
32 $this->error('Failed to parse JSON file');
33 return self::FAILURE;
34 }
35 
36 $this->info("Importing " . count($rules) . " rules to collection: {$collection}");
37 
38 $baseUrl = sprintf(
39 '%s://%s:%s/collections/%s/overrides',
40 $config['nodes'][0]['protocol'],
41 $config['nodes'][0]['host'],
42 $config['nodes'][0]['port'],
43 urlencode($collection)
44 );
45 
46 $success = 0;
47 $failed = 0;
48 
49 $progressBar = $this->output->createProgressBar(count($rules));
50 
51 foreach ($rules as $rule) {
52 $overrideId = $rule['id'];
53 unset($rule['id']);
54 
55 $response = Http::withHeaders([
56 'X-TYPESENSE-API-KEY' => $config['api_key'],
57 ])->put("{$baseUrl}/" . urlencode($overrideId), $rule);
58 
59 if ($response->successful()) {
60 $success++;
61 } else {
62 $failed++;
63 $this->newLine();
64 $this->warn("Failed: {$overrideId} - {$response->body()}");
65 }
66 
67 $progressBar->advance();
68 }
69 
70 $progressBar->finish();
71 $this->newLine(2);
72 
73 $this->info("Import complete! Success: {$success}, Failed: {$failed}");
74 
75 return $failed > 0 ? self::FAILURE : self::SUCCESS;
76 }
77}

Run it with:

1php artisan typesense:import-overrides storage/converted_typesense_rules.json
2# Or specify a collection name:
3php artisan typesense:import-overrides storage/converted_typesense_rules.json --collection=my_products

Important: What Won't Convert

Not all Algolia rules have Typesense equivalents. The conversion tool handles:

  • Pinned results (promoteincludes) - Pin specific products at positions
  • Hidden results (hideexcludes) - Hide specific products from results

But these Algolia features cannot be converted:

  • Dynamic filters - Closest equivalent to boosting a column (eg boost a brand or category higher for a search term). More info
  • optionalFilters - Brand/category boosting like brand:Nike<score=2> has no direct equivalent, dynamic filters are close but not exactly the same as boosting.
  • Query rewrites - Replacing or modifying the search query

In my migration, 98 of 145 rules converted successfully. The 47 that failed all used optionalFilters for brand boosting. I ended up using the dynamic filters to achieve this for my scenarios, but am awaiting confirmation from Typesense support as to whether this is the recommended solution. Otherwise there's also sort_by with _eval to achieve boosting, such as: sort_by=_eval(brand:nike):desc,_text_match:desc, or if you wish to boost multiple brands at once, you can use an array with weighting: _eval([ (brand:=ABC):4, (brand:=DEF):3, (brand:=GHI):2, (brand:=JKL):1 ]):desc,_text_match(buckets: 5):desc,avg3mth:desc

The Result

After migration:

  • Cost reduction: ~65% savings compared to new Algolia pricing
  • Performance: Comparable search speeds, often faster for complex queries
  • Developer experience: Minimal frontend code changes thanks to the InstantSearch adapter
  • Reliability: Typesense Cloud's 3-node HA cluster has been rock solid
  • No more replica indices: Sorting doesn't require maintaining separate indices

The migration took approximately one week of development time, including testing and refinement. The hardest part was understanding Typesense's schema requirements and filter syntax differences - once past that learning curve, everything fell into place.

Quick Reference: Algolia vs Typesense

Feature Algolia Typesense
Schema Schema-less Predefined schema required
Sort indices Replica indices Path-based (index/sort/field:dir)
Filter syntax ->where('field', value) ->options(['filter_by' => 'field:=value'])
Field weights Inline (field:weight) Separate parameter
Max sort fields Unlimited 3
Analytics Built-in widgets Not supported in adapter
Query rules UI Full visual builder Basic pin/hide only
Rules import/export JSON via dashboard API only
Brand boosting optionalFilters with scores Dynamic filtering or sort_by with _eval()
Pricing Per-search + records Cluster size + bandwidth

Conclusion

If you're facing similar Algolia pricing concerns, Typesense is a worthy alternative. The InstantSearch adapter makes migration straightforward, and the cost savings are substantial. While it requires a bit more configuration upfront with the schema definition, the trade-off is well worth it for the long-term savings and comparable functionality.

The key is understanding the syntax differences early - filter formats, sort-by patterns, and field weights. Once you've got those down, the rest is smooth sailing.

Useful Resources


Considering migrating from Algolia to Typesense, or need help implementing search in your Laravel application? I specialise in Laravel development and search integrations. Get in touch to discuss your project.

Syntax highlighting by Torchlight

More articles

Laravel CSV Import Validation: Preventing XSS with League CSV

When building applications that accept CSV file uploads, there's a critical security concern that's easy to overlook. The data inside those files can be just as dangerous as any user input.

Read article

Livewire vs Intertia in Laravel: Do you need the extra complexity?

At Laracon AU 2025, the debate between Livewire and JavaScript frameworks was a hot topic. Here's why Livewire might be all you need for real-time reactivity in your Laravel applications.

Read article

Talk to me about your website project