Skip to content

Architecture

Current state of Loupe Search as implemented in this repository. Every claim below is traceable to a named symbol in includes/.

Loupe Search maintains one Loupe SQLite index per configured post type and exposes it to three consumers: the WordPress front-end search query, a REST API, and the WordPress Abilities API. Indexing is incremental on save_post and can be rebuilt in full or in batches from the admin Dashboard or WP-CLI.

Included:

  • Per-post-type index lifecycle: create, incremental update, delete, rebuild.
  • Field schema configuration (weight, filterable, sortable) per post type.
  • Query interception for the front-end WordPress search.
  • A public search REST API and an admin-only management REST API.
  • Abilities API registration for agent/automation consumers.
  • A single admin settings screen with index health and a batched reindex runner.

Not included:

  • A front-end search UI. The plugin ships no blocks or shortcodes; the block editor integration was removed (tests/WP_Loupe_Blocks_EditorTest.php is skipped with “WP Loupe no longer ships the block editor integration”).
  • Admin-side search over posts, users, comments and plugins. That is the separate wp-loupe-admin-search add-on, which writes its own indexes under <db path>/admin/.
  • Indexing of non-published content. See Invariants.
  • A plugin update channel. See ADR 0001.
  • An MCP server. Removed in 0.8.5; see migration-mcp-to-abilities.md.
ComponentResponsibilityImplementationDepends on
BootstrapDefines WP_LOUPE_* constants, gates unwanted request types, boots the loader on plugins_loadedloupe-search.phpWP_Loupe_Loader, WP_Loupe_Utils
LoaderRequires every class file and wires the runtime componentsincludes/class-wp-loupe-loader.phpall components below
Path resolverResolves the index base directory and per-post-type directory; deletes the index treeincludes/class-wp-loupe-db.phploupe_search_db_path filter
Engine factoryBuilds and caches a configured Loupe\Loupe\Loupe per post type, and decides which fields may be sortableincludes/class-wp-loupe-factory.phploupe/loupe, wp_loupe_fields, wp_loupe_advanced
Schema managerTurns saved field settings into indexable / filterable / sortable field lists, with per-request cachingincludes/class-wp-loupe-schema-manager.phpwp_loupe_fields
IndexerDocument preparation, incremental add/delete, full and batched reindex, on-disk column repairincludes/class-wp-loupe-indexer.phpfactory, path resolver, schema manager
Search engineSide-effect-free querying, result caching, index-readiness probingincludes/class-wp-loupe-search-engine.phpfactory, path resolver
Search hooksFront-end WP_Query interception and footer timing commentincludes/class-wp-loupe-search-hooks.phpsearch engine
REST controllerRoute registration in two namespaces, request validation, allowlisting, response shapingincludes/class-wp-loupe-rest.phpsearch engine, schema manager, indexer
AbilitiesRegisters loupe-search/search and loupe-search/get-post (plus deprecated aliases)includes/class-wp-loupe-abilities.phpsearch engine
Settings screenSettings → Loupe Search page, option registration and sanitization, admin assetsincludes/class-wp-loupe-settings.phpfactory, REST controller (via admin JS)
CLIwp loupe-search reindexincludes/class-wp-loupe-cli.phpindexer
UtilitiesPost-type resolution, environment checks, debug logging, transient purgingincludes/class-wp-loupe-utils.php

All classes live in the Soderlind\Plugin\WPLoupe namespace. Class names and the WP_LOUPE_* constants retain the historic WP_Loupe prefix; see renamed-from-wp-loupe.md.

flowchart TD
    subgraph Consumers
        FE[Front-end search query]
        REST[REST clients]
        AB[Abilities API consumers]
        ADMIN[Settings screen]
        CLI[WP-CLI]
    end

    FE --> HOOKS[WP_Loupe_Search_Hooks]
    REST --> RESTC[WP_Loupe_REST]
    AB --> ABIL[WP_Loupe_Abilities]
    ADMIN -->|admin.js| RESTC
    CLI --> IDX[WP_Loupe_Indexer]

    HOOKS --> ENG[WP_Loupe_Search_Engine]
    RESTC --> ENG
    RESTC --> IDX
    ABIL --> ENG

    IDX --> UTIL["WP_Loupe_Utils::get_indexed_post_types()"]
    RESTC --> UTIL
    ABIL --> UTIL

    ENG --> FACT[WP_Loupe_Factory]
    IDX --> FACT
    IDX --> SCHEMA[WP_Loupe_Schema_Manager]
    RESTC --> SCHEMA
    FACT --> SCHEMA
    FACT --> DB[WP_Loupe_DB]
    DB --> FS[(loupe-search-db/&lt;post_type&gt;/loupe.db)]
    FACT --> FS

init() in loupe-search.php returns early — so nothing is wired at all — for autosaves, heartbeat AJAX and cron.

WP_Loupe_Loader::init_components() then decides which integrations attach:

IntegrationAttached when
WPLoupe_Settings_Pagealways
WP_Loupe_Search_Enginealways
WP_Loupe_Search_Hooks( ! is_admin() || wp_doing_ajax() ) and not REST_REQUEST and not WP_CLI
WP_Loupe_Indexeralways
WP_Loupe_RESTalways
WP_Loupe_Abilitiesalways; it only attaches to wp_abilities_api_init, so registration happens only where the WordPress 6.9+ Abilities API is present

The indexed post-type list is resolved by WP_Loupe_Utils::get_indexed_post_types(), which reads get_option( 'wp_loupe_custom_post_types' )['wp_loupe_post_type_field'], defaults to [ 'post', 'page' ], then applies loupe_search_post_types. The loader, indexer, REST controller and abilities all call it, so the filter has the same effect on every surface. The settings screen deliberately reads the raw option instead, so the checkboxes always show what is stored.

Trigger: a front-end WP_Query search.

  1. WP_Loupe_Search_Hooks::posts_pre_query() runs on the posts_pre_query filter.
  2. should_intercept_query() bails unless this is a front-end main search query (or any AJAX search query). If the query names explicit post types and any of them is not indexed, it bails so WordPress can answer the query itself.
  3. prepare_search_term() joins search_terms, wrapping multi-word terms in quotes for phrase matching.
  4. WP_Loupe_Search_Engine::search() checks the wp_loupe_search_<md5> transient, then queries every configured post type’s Loupe instance and merges the hits. Loupe’s _rankingScore is copied to _score, and post_type is stamped on each hit. Results are cached for one hour (CACHE_TTL).
  5. create_post_objects() loads the matching WP_Post objects with orderby => post__in to preserve relevance order, and attaches every filterable or sortable field as a dynamic property.
  6. posts_pre_query() slices the result to the current page, sets found_posts, max_num_pages and is_paged on the query, and returns the page. Page size comes from loupe_search_posts_per_page.
  7. action_wp_footer() prints the accumulated Loupe processing time as an HTML comment.

Failure behavior:

FailureBehaviorRetry owner
Loupe throws for one post typeLogged via WP_Loupe_Utils::debug_log(), that post type contributes no hits, other post types still returnnone
Sort attribute rejected by LoupeLogged, the search re-runs without sortingnone
Query is not interceptableposts_pre_query() returns null, WordPress runs its own SQL searchWordPress

Ranking is computed per post type and the merged list is not re-ranked across post types in this path; WP_Loupe_REST::handle_search_request_post() sorts the merged set explicitly.

Trigger: save_post_{$post_type} for each configured post type.

  1. WP_Loupe_Indexer::add() calls is_indexable(), which rejects revisions, autosaves, unconfigured post types, non-publish statuses, and password-protected posts unless loupe_search_index_protected says otherwise.
  2. All wp_loupe_search_* transients are deleted via WP_Loupe_Utils::remove_transient(), invalidating both the simple and advanced search caches.
  3. prepare_document() builds the document: id (the post ID) and post_type, then every indexable field resolved from — in order — a WP_Post property (passed through loupe_search_field_{$field_name}), a taxonomy_<name> term-name array, or post meta. Values are normalized by sanitize_field_value() to null, a number, a string, an array of strings, or a { lat, lng } geo point. Sortable fields absent from the document are backfilled with "" so the attribute always exists.
  4. Loupe::addDocument() writes to <db path>/<post_type>/loupe.db.

Trashing is handled by trash_post() on wp_trash_post, which only acts when the previous status was publish, verifies the bulk-posts nonce for bulk operations, and routes to deleteDocument() or deleteDocuments().

Loupe 0.13 changed the internal document schema. delete() and delete_many() therefore call maybe_migrate_loupe_before_delete() first — which indexes the current document to trigger Loupe’s own migration — and retry once if the exception message matches LOUPE_SCHEMA_MISMATCH_SUBSTRINGS (no such column: _user_id, no such column: _id). A second failure is logged and swallowed.

Trigger: POST /wp-json/loupe-search/v1/reindex-batch from the Dashboard’s reindex panel, or wp loupe-search reindex.

  1. WP_Loupe_Indexer::reindex_batch_init() repairs missing columns (ensure_required_columns()), purges search transients, writes default field settings when missing, clears the factory and schema caches, rebuilds the Loupe instances, and returns a state array (post_types, idx, last_id, cleared, processed, processed_pt).
  2. reindex_batch_step() processes one batch (10–2000 posts, default 500). The first step for a post type clears its index: if Loupe::needsReindex() is true, or deleteAllDocuments() raises a schema-mismatch error, the on-disk directory is deleted outright by delete_index_for_post_type() and the instance is rebuilt.
  3. Posts are fetched by keyset pagination — a prepared SELECT ID … WHERE ID > %d ORDER BY ID ASC LIMIT %d in get_published_post_ids_after() — so large sites never issue a large-offset query.
  4. The REST handler returns the updated state as a cursor, signed with hash_hmac( 'sha256', $payload, wp_salt( 'auth' ) ) and compared with hash_equals() on the next request. A cursor that fails verification or carries a different REINDEX_CURSOR_VERSION is discarded and the run restarts.
  5. The caller loops until the response reports done.

reindex_all() performs the same work synchronously in one request and is used by the legacy admin_init form handler (handle_reindex(), guarded by the wp_loupe_nonce_action nonce).

Failure behavior:

FailureBehaviorRetry owner
Batch step throwsREST returns wp_loupe_reindex_failed (HTTP 500); the last valid cursor is still held by the clientcaller
Cursor tampered with or stale versionCursor rejected, a fresh run is initializedsystem
ensure_required_columns() cannot open the SQLite fileSilently ignored; the index is rebuilt from scratch on schema mismatchsystem
  • Query vs. engine. WP_Loupe_Search_Engine registers no hooks and must stay side-effect free; all WordPress query integration lives in WP_Loupe_Search_Hooks. Enforced by convention and asserted by tests/WP_Loupe_Search_HooksTest.php.
  • Front-end only interception. WP_Loupe_Search_Hooks is never constructed during REST, WP-CLI or non-AJAX admin requests, so admin list tables keep WordPress’ own search.
  • Persistence boundary. Index data lives in SQLite files outside MySQL and is accessed only through Loupe\Loupe\Loupe, except for two deliberate PDO escapes: ensure_required_columns() in the indexer and the version probe in WP_Loupe_Utils::get_sqlite_version_string(). There is no transaction that spans MySQL and the index.
  • Trust boundary — public REST. GET /search and POST /search use permission_callback => '__return_true' and are unauthenticated. All other routes require manage_options. Untrusted input becomes trusted in handle_search_request_post(), which:
    • rejects post types not in the configured list and not index-ready;
    • validates every attribute name against WP_Loupe_Search_Engine::is_valid_loupe_attribute_name();
    • requires filter, facet and geo fields to be filterable in every requested post type (get_common_allows() intersects the sets);
    • accepts only the operators eq, ne, in, nin, lt, lte, gt, gte, between, exists in build_filter_string();
    • drops unknown highlight/crop fields silently and passes highlight tags through wp_kses() with an inline-formatting allowlist (sanitize_highlight_tag()).
  • Pagination ceiling. POST /search rejects requests where offset + size > 1000, and WP_Loupe_Search_Engine::search() caps Loupe at withLimit( 1000 ). Deep pagination is not supported.
  • Cache coherence. Every index write purges the wp_loupe_search_ transient prefix. Because the advanced cache key is wp_loupe_search_adv_<md5>, the prefix purge covers it too.
  • No cross-post-type ranking in the query path. See Flow: front-end search.

The index contains only published, indexable posts

Section titled “The index contains only published, indexable posts”

Rule: a document exists in <post_type>/loupe.db only for a post of that type whose status is publish, which is not a revision or autosave, and which is either unprotected or admitted by loupe_search_index_protected.

Enforced by WP_Loupe_Indexer::is_indexable() on write, post_status => 'publish' in reindex_all() and get_published_post_ids_after(), and trash_post() on removal.

Every read path re-checks the status of each hit, so a stale index degrades to missing results rather than leaking drafts: WP_Loupe_REST::handle_search_request_post() and perform_search(), WP_Loupe_Abilities::execute_search() and execute_get_post(), and WP_Loupe_Search_Hooks::create_post_objects(), which hydrates hits with get_posts() and therefore inherits its publish-only default.

Rule: Configuration::withPrimaryKey( 'id' ) and prepare_document() sets id => $post->ID. Deletes address documents by post ID.

Enforced by WP_Loupe_Factory::build_configuration() and WP_Loupe_Indexer::prepare_document().

Sortable attributes are scalar or geo points

Section titled “Sortable attributes are scalar or geo points”

Rule: an attribute may be declared sortable only if it is a known scalar core field, or a meta field whose sampled values are scalar or a valid { lat, lng } geo point.

Enforced by WP_Loupe_Factory::is_safely_sortable() and check_meta_field_sortability(), which samples five posts. validate_sortable_fields() rewrites and re-saves wp_loupe_fields when a setting violates this. Overridable per site through loupe_search_is_safely_sortable_{$post_type} and loupe_search_is_safely_sortable_meta_{$post_type}.

Verified by tests/WP_Loupe_Factory_GeoSortableTest.php.

Rule: post_date is indexable, filterable and sortable for every post type, independent of saved settings. It is the only field in the baseline schema.

Enforced by WP_Loupe_Schema_Manager::get_default_schema(). Older indexes are repaired by WP_Loupe_Indexer::ensure_required_columns(), which adds the SQLite column and its index if missing.

Verified by tests/SchemaManagerTest.php and tests/WP_Loupe_Schema_ManagerTest.php.

Every surface resolves the same post-type list

Section titled “Every surface resolves the same post-type list”

Rule: the indexer, the REST controller, the abilities and the front-end query integration all operate on one list, so loupe_search_post_types cannot make a post type searchable without also making it indexed.

Enforced by WP_Loupe_Utils::get_indexed_post_types(), the single reader of wp_loupe_custom_post_types outside the settings screen. WPLoupe_Settings_Page is the deliberate exception: it reads the raw option so the checkboxes show what is stored rather than what the filter produced.

Verified by tests/WP_Loupe_UtilsTest.php.

Renamed hooks, routes, commands and abilities keep working

Section titled “Renamed hooks, routes, commands and abilities keep working”

Rule: every public name renamed in 1.1.0/1.2.0 still resolves under its old name.

Enforced by paired apply_filters() + apply_filters_deprecated() calls for all eight filters, a foreach over [ 'loupe-search/v1', 'wp-loupe/v1' ] in WP_Loupe_REST::on_rest_api_init(), duplicate ability registration in WP_Loupe_Abilities::register_abilities(), and a second WP_CLI::add_command( 'wp-loupe', … ) with a deprecation warning.

Verified for abilities by tests/WP_Loupe_AbilitiesTest.php.

DataLocationOwnerRemoved on uninstall
Search indexeswp-content/loupe-search-db/<post_type>/loupe.dbWP_Loupe_DB, WP_Loupe_Factoryyes, including the legacy wp-loupe-db directory
Indexed post typesoption wp_loupe_custom_post_typessettings screenyes
Field settingsoption wp_loupe_fieldssettings screen, factory, indexeryes
Search behavior settingsoption wp_loupe_advancedsettings screenyes
Result cachestransients wp_loupe_search_*search engine, REST controlleryes

WP_Loupe_DB::get_base_path() defaults to WP_CONTENT_DIR . '/loupe-search-db' but falls back to the legacy wp-loupe-db directory when that exists and the new one does not, so upgraded sites keep their indexes. The result is passed through loupe_search_db_path; an empty or non-string filter return is ignored.

uninstall.php deletes both directories, the three options and the cached search results. On multisite it repeats the option and transient cleanup for every site.

ChangePrimary locationAlso update
Add or change an indexed field’s valueWP_Loupe_Indexer::prepare_document()docs/filters.md if a new filter is introduced
Change the default field schemaWP_Loupe_Schema_Manager::get_default_schema(), WP_Loupe_Factory::get_field_configuration()tests/SchemaManagerTest.php
Change Loupe engine configuration (typo tolerance, tokenization, languages)WP_Loupe_Factory::build_configuration()settings fields in WPLoupe_Settings_Page::wp_loupe_setup_fields(), sanitize_advanced_settings()
Add a REST route or parameterWP_Loupe_REST::register_routes_for_namespace() and its handlerdocs/search-api.md, tests/WP_Loupe_REST_*
Change how front-end search results are ordered or paginatedWP_Loupe_Search_Hooks::posts_pre_query()tests/WP_Loupe_Search_HooksTest.php
Change which post types are indexed and searchedWP_Loupe_Utils::get_indexed_post_types()tests/WP_Loupe_UtilsTest.php
Change where indexes are storedWP_Loupe_DB::get_base_path()uninstall.php, and the add-on’s WP_Loupe_Admin_Paths
Add a settings fieldWPLoupe_Settings_Page::wp_loupe_setup_fields()matching sanitize_* method, tests/WP_Loupe_SettingsTest.php
Add an abilityWP_Loupe_Abilities::register_abilities()tests/WP_Loupe_AbilitiesTest.php
Rename a public hook, route or commandthe emitting call siteadd an apply_filters_deprecated() / alias registration, and docs/renamed-from-wp-loupe.md

Run composer test (PHPUnit, WordPress functions mocked with Brain Monkey) or composer test:pest after changes. There is no integration suite that exercises a real Loupe index.

  • WP_Loupe_Search (includes/class-wp-loupe-search.php) is deprecated since 0.6.0 and is never instantiated by the plugin. It is loaded by the loader and kept only for third-party code that constructs it directly.
  • WP_Loupe_REST::format_search_results() is annotated in-code as retained for backward compatibility.