PageSourceSearch

https://docs.flarum.org/assets/js/6a22739d.803a6f02.js

js flarum.org collected 2026-09-24 18:16:07 UTC 20,181 bytes, 1 lines download raw bytes

1"use strict";(globalThis.webpackChunkflarum_docs=globalThis.webpackChunkflarum_docs||[]).push([[6233],{8079(e,r,t){t.r(r),t.d(r,{assets:()=>o,contentTitle:()=>l,default:()=>h,frontMatter:()=>i,metadata:()=>n,toc:()=>c});const n=JSON.parse('{"id":"extend/search","title":"Searching and Filtering","description":"Flarum treats searching and filtering as parallel but distinct processes. Which process is used to handle a request to a List API endpoint depends on the query parameters:","source":"@site/versioned_docs/version-1.x/extend/search.md","sourceDirName":"extend","slug":"/extend/search","permalink":"/1.x/extend/search","draft":false,"unlisted":false,"editUrl":"https://github.com/flarum/docs/tree/main/versioned_docs/version-1.x/extend/search.md","tags":[],"version":"1.x","frontMatter":{},"sidebar":"extendSidebar","previous":{"title":"Post Types","permalink":"/1.x/extend/post-types"},"next":{"title":"Service Provider","permalink":"/1.x/extend/service-provider"}}');var s=t(4848),a=t(8453);const i={},l="Searching and Filtering",o={},c=[{value:"Filtering",id:"filtering",level:2},{value:"Modify Filtering for an Existing Model",id:"modify-filtering-for-an-existing-model",level:3},{value:"Add Filtering to a New Model",id:"add-filtering-to-a-new-model",level:3},{value:"Searching",id:"searching",level:2},{value:"Modify Searching for an Existing Model",id:"modify-searching-for-an-existing-model",level:3},{value:"Add Searching to a New Model",id:"add-searching-to-a-new-model",level:3},{value:"Search Drivers",id:"search-drivers",level:3},{value:"Frontend Tools",id:"frontend-tools",level:2}];function d(e){const r={a:"a",admonition:"admonition",code:"code",em:"em",h1:"h1",h2:"h2",h3:"h3",header:"header",li:"li",ol:"ol",p:"p",pre:"pre",strong:"strong",ul:"ul",...(0,a.R)(),...e.components};return(0,s.jsxs)(s.Fragment,{children:[(0,s.jsx)(r.header,{children:(0,s.jsx)(r.h1,{id:"searching-and-filtering",children:"Searching and Filtering"})}),"\n",(0,s.jsxs)(r.p,{children:["Flarum treats searching and filtering as parallel but distinct processes. Which process is used to handle a request to a ",(0,s.jsxs)(r.a,{href:"/1.x/extend/api#api-endpoints",children:[(0,s.jsx)(r.code,{children:"List"})," API endpoint"]})," depends on the query parameters:"]}),"\n",(0,s.jsxs)(r.ul,{children:["\n",(0,s.jsxs)(r.li,{children:["Filtering is applied when the ",(0,s.jsx)(r.code,{children:"filter[q]"})," query param is omitted. Filters represent ",(0,s.jsx)(r.strong,{children:"structured"})," queries: for instance, you might want to only retrieve discussions in a certain category, or users who registered before a certain date. Filtering computes results based entirely on ",(0,s.jsx)(r.code,{children:"filter[KEY] = VALUE"})," query parameters."]}),"\n",(0,s.jsxs)(r.li,{children:["Searching is applied when the ",(0,s.jsx)(r.code,{children:"filter[q]"})," query param is included. Searches represent ",(0,s.jsx)(r.strong,{children:"unstructured"}),' queries: the user submits an arbitrary string, and data records that "match" it are returned. For instance, you might want to search discussions based on the content of their posts, or users based on their username. Searching computes results based solely on parsing the ',(0,s.jsx)(r.code,{children:"filter[q]"})," query param: all other ",(0,s.jsx)(r.code,{children:"filter[KEY] = VALUE"})," params are ignored when searching. It's important to note that searches aren't entirely unstructured: the dataset being searched can be constrained by gambits (which are very similar to filters, and will be explained later)."]}),"\n"]}),"\n",(0,s.jsxs)(r.p,{children:["This distinction is important because searches and filters have very different use cases: filters represent ",(0,s.jsx)(r.em,{children:"browsing"}),": that is, the user is passively looking through some category of content. In contrast, searches represent, well, ",(0,s.jsx)(r.em,{children:"searching"}),": the user is actively looking for content based on some criteria."]}),"\n",(0,s.jsxs)(r.p,{children:["Flarum implements searching and filtering via per-model ",(0,s.jsx)(r.code,{children:"Searcher"})," and ",(0,s.jsx)(r.code,{children:"Filterer"})," classes (discussed in more detail below). Both classes accept a ",(0,s.jsx)(r.a,{href:"https://api.docs.flarum.org/php/master/flarum/query/querycriteria",children:(0,s.jsx)(r.code,{children:"Flarum\\Query\\QueryCriteria"})})," instance (a wrapper around the user and query params), and return a ",(0,s.jsx)(r.a,{href:"https://api.docs.flarum.org/php/master/flarum/query/queryresults",children:(0,s.jsx)(r.code,{children:"Flarum\\Query\\QueryResults"})})," instance (a wrapper around an Eloquent model collection). This common interface means that adding search/filter support to models is quite easy."]}),"\n",(0,s.jsxs)(r.p,{children:["One key advantage of this split is that it allows searching to be implemented via an external service, such as ElasticSearch. For larger communities, this can be significantly more performant and accurate. There isn't a dedicated extender for this yet, so for now, replacing the default Flarum search driver requires overriding the container bindings of ",(0,s.jsx)(r.code,{children:"Searcher"})," classes. This is a highly advanced use case; if you're interested in doing this, please reach out on our ",(0,s.jsx)(r.a,{href:"https://discuss.flarum.org/",children:"community forums"}),"."]}),"\n",(0,s.jsxs)(r.p,{children:["Remember that the ",(0,s.jsxs)(r.a,{href:"https://jsonapi.org/format",children:["JSON",":API"," schema"]})," is used for all API requests."]}),"\n",(0,s.jsx)(r.admonition,{title:"Reuse Code",type:"tip",children:(0,s.jsxs)(r.p,{children:["Often, you might want to use the same class as both a ",(0,s.jsx)(r.code,{children:"Filter"})," and a ",(0,s.jsx)(r.code,{children:"Gambit"}
1)," (both explained below).\nYour classes can implement both interface; see Flarum core's ",(0,s.jsx)(r.a,{href:"https://github.com/flarum/framework/blob/main/framework/core/src/Discussion/Query/UnreadFilterGambit.php",children:(0,s.jsx)(r.code,{children:"UnreadFilterGambit"})})," for an example."]})}),"\n",(0,s.jsx)(r.admonition,{title:"Query Builder vs Eloquent Builder",type:"tip",children:(0,s.jsxs)(r.p,{children:[(0,s.jsx)(r.code,{children:"Filter"}),"s, ",(0,s.jsx)(r.code,{children:"Gambit"}),'s, filter mutators, and gambit mutators (all explained below) receive a "state" parameter, which wraps']})}),"\n",(0,s.jsx)(r.h2,{id:"filtering",children:"Filtering"}),"\n",(0,s.jsxs)(r.p,{children:["Filtering constrains queries based on ",(0,s.jsx)(r.code,{children:"Filters"})," (highlighted in code to avoid confusion with the process of filtering), which are classes that implement ",(0,s.jsx)(r.code,{children:"Flarum\\Filter\\FilterInterface"}),' and run depending on query parameters. After filtering is complete, a set of callbacks called "filter mutators" run for every filter request.']}),"\n",(0,s.jsxs)(r.p,{children:["When the ",(0,s.jsx)(r.code,{children:"filter"})," method on a ",(0,s.jsx)(r.code,{children:"Filterer"})," class is called, the following process takes place (",(0,s.jsx)(r.a,{href:"https://github.com/flarum/framework/blob/main/framework/core/src/Filter/AbstractFilterer.php#L50-L93",children:"relevant code"}),"):"]}),"\n",(0,s.jsxs)(r.ol,{children:["\n",(0,s.jsxs)(r.li,{children:["An Eloquent query builder instance for the model is obtained. This is provided by the per-model ",(0,s.jsx)(r.code,{children:"{MODEL_NAME}Filterer"})," class's ",(0,s.jsx)(r.code,{children:"getQuery()"})," method."]}),"\n",(0,s.jsxs)(r.li,{children:["We loop through all ",(0,s.jsx)(r.code,{children:"filter[KEY] = VALUE"})," query params. For each of these, any ",(0,s.jsx)(r.code,{children:"Filter"}),"s registered to the model whose ",(0,s.jsx)(r.code,{children:"getFilterKey()"})," method matches the query param ",(0,s.jsx)(r.code,{children:"KEY"})," is applied. ",(0,s.jsx)(r.code,{children:"Filter"}),"s can be negated by providing the query param as ",(0,s.jsx)(r.code,{children:"filter[-KEY] = VALUE"}),". Whether or not a ",(0,s.jsx)(r.code,{children:"Filter"})," is negated is passed to it as an argument: implementing negation is up to the ",(0,s.jsx)(r.code,{children:"Filter"}),"s."]}),"\n",(0,s.jsxs)(r.li,{children:[(0,s.jsx)(r.a,{href:"https://jsonapi.org/format/#fetching-sorting",children:"Sorting"}),", ",(0,s.jsx)(r.a,{href:"https://jsonapi.org/format/#fetching-pagination",children:"pagination"})," are applied."]}),"\n",(0,s.jsx)(r.li,{children:'Any "filter mutators" are applied. These are callbacks that receive the filter state (a wrapper around the query builder and current user) and filter criteria, and perform some arbitrary changes. All "filter mutators" run on any request.'}),"\n",(0,s.jsxs)(r.li,{children:["We calculate if there are additional matching model instances beyond the query set we're returning for this request, and return this value along with the actual model data, wrapped in a ",(0,s.jsx)(r.code,{children:"Flarum\\Query\\QueryResults"})," object."]}),"\n"]}),"\n",(0,s.jsx)(r.h3,{id:"modify-filtering-for-an-existing-model",children:"Modify Filtering for an Existing Model"}),"\n",(0,s.jsxs)(r.p,{children:["Let's say you've added a ",(0,s.jsx)(r.code,{children:"country"})," column to the User model, and would like to filter users by country. We'll need to define a custom ",(0,s.jsx)(r.code,{children:"Filter"}),":"]}),"\n",(0,s.jsx)(r.pre,{children:(0,s.jsx)(r.code,{className:"language-php",children:"<?php\n\nnamespace YourPackage\\Filter;\n\nuse Flarum\\Filter\\FilterInterface;\nuse Flarum\\Filter\\FilterState;\n\nclass CountryFilter implements FilterInterface\n{\n    public function getFilterKey(): string\n    {\n        return 'country';\n    }\n\n    public function filter(FilterState $filterState, string $filterValue, bool $negate)\n    {\n        $country = trim($filterValue, '\"');\n\n        $filterState->getQuery()->where('users.country', $negate ? '!=' : '=', $country);\n    }\n}\n"})}),"\n",(0,s.jsxs)(r.p,{children:["Note that ",(0,s.jsx)(r.code,{children:"FilterState"})," is a wrapper around the Eloquent builder's underlying Query builder and the current user."]}),"\n",(0,s.jsx)(r.p,{children:'Also, let\'s pretend that for some reason, we want to omit any users that have a different c
1ountry from the current user on ANY filter.\nWe can use a "filter mutator" for this:'}),"\n",(0,s.jsx)(r.pre,{children:(0,s.jsx)(r.code,{className:"language-php",children:"<?php\n\nnamespace YourPackage\\Filter;\n\nuse Flarum\\Filter\\FilterState;\nuse Flarum\\Query\\QueryCriteria;\n\nclass OnlySameCountryFilterMutator\n{\n    public function __invoke(FilterState $filterState, QueryCriteria $queryCriteria)\n    {\n        $filterState->getQuery()->where('users.country', $filterState->getActor()->country);\n    }\n}\n"})}),"\n",(0,s.jsx)(r.p,{children:"Now, all we need to do is register these via the Filter extender:"}),"\n",(0,s.jsx)(r.pre,{children:(0,s.jsx)(r.code,{className:"language-php",children:"  // Other extenders\n  (new Extend\\Filter(UserFilterer::class))\n    ->addFilter(CountryFilter::class)\n    ->addFilterMutator(OnlySameCountryFilterMutator::class),\n  // Other extenders\n"})}),"\n",(0,s.jsx)(r.h3,{id:"add-filtering-to-a-new-model",children:"Add Filtering to a New Model"}),"\n",(0,s.jsxs)(r.p,{children:["To filter a model that doesn't support filtering, you'll need to create a subclass of ",(0,s.jsx)(r.code,{children:"Flarum/Filter/AbstractFilterer"})," for that model.\nFor an example, see core's ",(0,s.jsx)(r.a,{href:"https://github.com/flarum/framework/blob/main/framework/core/src/User/Filter/UserFilterer.php",children:"UserFilterer"}),"."]}),"\n",(0,s.jsxs)(r.p,{children:["Then, you'll need to use that filterer in your model's ",(0,s.jsx)(r.code,{children:"List"})," controller. For an example, see core's ",(0,s.jsx)(r.a,{href:"https://github.com/flarum/framework/blob/main/framework/core/src/Api/Controller/ListUsersController.php#L93-L98",children:"ListUsersController"}),"."]}),"\n",(0,s.jsx)(r.h2,{id:"searching",children:"Searching"}),"\n",(0,s.jsxs)(r.p,{children:["Searching constrains queries by applying ",(0,s.jsx)(r.code,{children:"Gambit"}),"s, which are classes that implement ",(0,s.jsx)(r.code,{children:"Flarum\\Search\\GambitInterface"}),", based on the ",(0,s.jsx)(r.code,{children:"filter[q]"}),' query param.\nAfter searching is complete, a set of callbacks called "search mutators" run for every search request.']}),"\n",(0,s.jsxs)(r.p,{children:["When the ",(0,s.jsx)(r.code,{children:"search"})," method on a ",(0,s.jsx)(r.code,{children:"Searcher"})," class is called, the following process takes place (",(0,s.jsx)(r.a,{href:"https://github.com/flarum/framework/blob/main/framework/core/src/Search/AbstractSearcher.php#L55-L79",children:"relevant code"}),"):"]}),"\n",(0,s.jsxs)(r.ol,{children:["\n",(0,s.jsxs)(r.li,{children:["An Eloquent query builder instance for the model is obtained. This is provided by the per-model ",(0,s.jsx)(r.code,{children:"{MODEL_NAME}Searcher"})," class's ",(0,s.jsx)(r.code,{children:"getQuery()"})," method."]}),"\n",(0,s.jsxs)(r.li,{children:["The ",(0,s.jsx)(r.code,{children:"filter[q]"}),' param is split by spaces into "tokens". Each token is matched against the model\'s registered ',(0,s.jsx)(r.code,{children:"Gambit"}),"s (each gambit has a ",(0,s.jsx)(r.code,{children:"match"})," method). For any tokens that match a gambit, that gambit is applied, and the token is removed from the query string. Once all regular ",(0,s.jsx)(r.code,{children:"Gambit"}),"s have ran, all remaining unmatched tokens are passed to the model's ",(0,s.jsx)(r.code,{children:"FullTextGambit"}),", which implements the actual searching logic. For example if searching discussions, in the ",(0,s.jsx)(r.code,{children:"filter[q]"})," string ",(0,s.jsx)(r.code,{children:"'author:1 hello is:hidden' world"}),", ",(0,s.jsx)(r.code,{children:"author:1"})," and ",(0,s.jsx)(r.code,{children:"is:hidden"})," would get matched by core's Author and Hidden gambits, and ",(0,s.jsx)(r.code,{children:"'hello world'"})," (the remaining tokens) would be passed to the ",(0,s.jsx)(r.code,{children:"DiscussionFulltextGambit"}),"."]}),"\n",(0,s.jsxs)(r.li,{children:[(0,s.jsx)(r.a,{href:"https://jsonapi.org/format/#fetching-sorting",children:"Sorting"}),", ",(0,s.jsx)(r.a,{href:"https://jsonapi.org/format/#fetching-pagination",children:"pagination"})," are applied."]}),"\n",(0,s.jsx)(r.li,{children:'Any "search mutators" are applied. These are callbacks that receive the search state (a wrapper around the query builder and current user) and criteria, and perform some arbitrary changes. All "search mutators" run on any request.'}),"\n",(0,s.jsxs)(r.li,{children:["We calculate if there are additional matching model instances beyond the query set we're returning for this request, and return this value along with the actual model data, wrapped in a ",(0,s.jsx)(r.code,{children:"Flarum\\Query\\QueryResults"})," object."]}),"\n"]}),"\n",(0,s.jsx)(r.h3,{id:"modify-searching-for-an-existing-model",children:"Modify Searching for an Existing Model"}),"\n",(0,s.jsx)(r.p,{children:"Let's reuse the \"country\" examples we used above, and see how we'd implement the same things for searching:"}),"\n",(0,s.jsx)(r.pre,{children:(0,s.jsx)(r.code,{className:"language-php",children:"<?php\n\nnamespace YourPackage\\Search;\n\nuse Flarum\\Search\\AbstractRegexGambit;
1\nuse Flarum\\Search\\SearchState;\n\nclass CountryGambit extends AbstractRegexGambit\n{\n    public function getGambitPattern(): string\n    {\n        return 'country:(.+)';\n    }\n\n    public function conditions(SearchState $search, array $matches, bool $negate)\n    {\n        $country = trim($matches[1], '\"');\n\n        $search->getQuery()->where('users.country', $negate ? '!=' : '=', $country);\n    }\n}\n"})}),"\n",(0,s.jsx)(r.admonition,{title:"No Spaces in Gambit Patterns!",type:"warning",children:(0,s.jsxs)(r.p,{children:["Flarum splits the ",(0,s.jsx)(r.code,{children:"filter[q]"})," string into tokens by splitting it at spaces.\nThis means that your custom gambits can NOT use spaces as part of their pattern."]})}),"\n",(0,s.jsxs)(r.admonition,{title:"AbstractRegexGambit",type:"tip",children:[(0,s.jsxs)(r.p,{children:["All a gambit needs to do is implement ",(0,s.jsx)(r.code,{children:"Flarum\\Search\\GambitInterface"}),", which receives the search state and a token.\nIt should return if this gambit applies for the given token, and if so, make whatever mutations are necessary to the\nquery builder accessible as ",(0,s.jsx)(r.code,{children:"$searchState->getQuery()"}),"."]}),(0,s.jsxs)(r.p,{children:["However, for most gambits, the ",(0,s.jsx)(r.code,{children:"AbstractRegexGambit"})," abstract class (used above) should be used as a base class.\nThis makes it a lot simpler to match and apply gambits."]})]}),"\n",(0,s.jsx)(r.p,{children:"Similarly, the search mutator we need is almost identical to the filter mutator from before:"}),"\n",(0,s.jsx)(r.pre,{children:(0,s.jsx)(r.code,{className:"language-php",children:"<?php\n\nnamespace YourPackage\\Search;\n\nuse Flarum\\Query\\QueryCriteria;\nuse Flarum\\Search\\SearchState;\n\nclass OnlySameCountrySearchMutator\n{\n    public function __invoke(SearchState $searchState, QueryCriteria $queryCriteria)\n    {\n        $searchState->getQuery()->where('users.country', $filterState->getActor()->country);\n    }\n}\n"})}),"\n",(0,s.jsxs)(r.p,{children:["We can register these via the ",(0,s.jsx)(r.code,{children:"SimpleFlarumSearch"})," extender (in the future, the ",(0,s.jsx)(r.code,{children:"Search"})," extender will be used for registering custom search drivers):"]}),"\n",(0,s.jsx)(r.pre,{children:(0,s.jsx)(r.code,{className:"language-php",children:"  // Other extenders\n  (new Extend\\SimpleFlarumSearch(UserSearcher::class))\n    ->addGambit(CountryGambit::class)\n    ->addSearchMutator(OnlySameCountrySearchMutator::class),\n  // Other extenders\n"})}),"\n",(0,s.jsx)(r.h3,{id:"add-searching-to-a-new-model",children:"Add Searching to a New Model"}),"\n",(0,s.jsxs)(r.p,{children:["To support searching for a model, you'll need to create a subclass of ",(0,s.jsx)(r.code,{children:"Flarum/Search/AbstractSearcher"})," for that model.\nFor an example, see core's ",(0,s.jsx)(r.a,{href:"https://github.com/flarum/framework/blob/main/framework/core/src/User/Search/UserSearcher.php",children:"UserSearcher"}),"."]}),"\n",(0,s.jsxs)(r.p,{children:["Then, you'll need to use that searcher in your model's ",(0,s.jsx)(r.code,{children:"List"})," controller. For an example, see core's ",(0,s.jsx)(r.a,{href:"https://github.com/flarum/framework/blob/main/framework/core/src/Api/Controller/ListUsersController.php#L93-L98",children:"ListUsersController"}),"."]}),"\n",(0,s.jsxs)(r.p,{children:["Every searcher ",(0,s.jsx)(r.strong,{children:"must"})," have a fulltext gambit (the logic that actually does the searching). Otherwise, it won't be booted by Flarum, and you'll get an error.\nSee core's ",(0,s.jsx)(r.a,{href:"https://github.com/flarum/framework/blob/main/framework/core/src/User/Search/Gambit/FulltextGambit.php",children:"FulltextGambit for users"})," for an example.\nYou can set (or override) the full text gambit for a searcher via the ",(0,s.jsx)(r.code,{children:"SimpleFlarumSearch"})," extender's ",(0,s.jsx)(r.code,{children:"setFullTextGambit()"})," method."]}),"\n",(0,s.jsx)(r.h3,{id:"search-drivers",children:"Search Drivers"}),"\n",(0,s.jsx)(r.p,{children:"Coming soon!"}),"\n",(0,s.jsx)(r.h2,{id:"frontend-tools",children:"Frontend Tools"}),"\n",(0,s.jsx)(r.p,{children:"Coming soon!"})]})}function h(e={}){const{wrapper:r}={...(0,a.R)(),...e.components};return r?(0,s.jsx)(r,{...e,children:(0,s.jsx)(d,{...e})}):d(e)}},8453(e,r,t){t.d(r,{R:()=>i,x:()=>l});var n=t(6540);const s={},a=n.createContext(s);function i(e){const r=n.useContext(a);return n.useMemo(function(){return"function"==typeof e?e(r):{...r,...e}},[r,e])}function l(e){let r;return r=e.disableParentContext?"function"==typeof e.components?e.components(s):e.components||s:i(e.components),n.createElement(a.Provider,{value:r},e.children)}}}]);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.