Integrate Meilisearch — the lightning-fast, open-source search engine — into your Sylius store.
- Automatic indexing — entities are indexed when they change (via Doctrine lifecycle events) and can be (re)indexed in bulk with a console command
- Search page — a ready-made, faceted search experience for your shop, including taxon pages backed by Meilisearch
- Autocomplete — an instant-search widget powered by Meilisearch's official autocomplete library
- Facets & filters — declare facets, filters, and sortable fields with PHP attributes on a plain document class
- Synonyms — manage search synonyms in the Sylius admin; they are synced to Meilisearch automatically
- Extensible by design — data mappers, URL generators, entity filters, index scopes, and facet sorters are all pluggable services
- PHP
>= 8.1 - Sylius
1.14on Symfony6.4(the versions the plugin is built and tested against) - A running Meilisearch instance
composer require setono/sylius-meilisearch-pluginNo Symfony Flex recipe ships yet (#74 tracks adding one), so every step in this installation guide is manual — Flex will not register the bundle, configure it, or import the routing for you.
Add the plugin to your config/bundles.php before SyliusGridBundle:
Setono\SyliusMeilisearchPlugin\SetonoSyliusMeilisearchPlugin::class => ['all' => true],# config/packages/setono_sylius_meilisearch.yaml
setono_sylius_meilisearch:
indexes:
products:
document: 'Setono\SyliusMeilisearchPlugin\Document\Product'
entities: [ 'App\Entity\Product\Product' ]
search:
enabled: true
index: productsAdd your Meilisearch credentials to .env.local:
###> setono/sylius-meilisearch-plugin ###
MEILISEARCH_URL=http://localhost:7700 # required; server-side URL the PHP SDK connects to
MEILISEARCH_MASTER_KEY=YOUR_MASTER_KEY # required; used for indexing and settings
MEILISEARCH_SEARCH_KEY=YOUR_SEARCH_KEY # required for search/autocomplete (search-only key)
MEILISEARCH_PUBLIC_URL= # optional; browser-facing URL for the autocomplete widget when MEILISEARCH_URL is an internal hostname. Falls back to MEILISEARCH_URL when empty
MEILISEARCH_PREFIX= # optional; useful when developers share a Meilisearch instance
###< setono/sylius-meilisearch-plugin ###Index names are automatically prefixed with the kernel environment (and your optional MEILISEARCH_PREFIX), so dev, test, and prod never collide.
The full list of options is always available via:
php bin/console config:dump-reference setono_sylius_meilisearch# config/routes/setono_sylius_meilisearch.yaml
setono_sylius_meilisearch:
resource: "@SetonoSyliusMeilisearchPlugin/Resources/config/routes.yaml"or if your app doesn't use locales:
# config/routes/setono_sylius_meilisearch.yaml
setono_sylius_meilisearch:
resource: "@SetonoSyliusMeilisearchPlugin/Resources/config/routes_no_locale.yaml"This registers the shop search page (/search), the taxon search page (/taxons/{slug}), the search widget endpoint, and the synonym CRUD in the admin.
Every entity you index must implement Setono\SyliusMeilisearchPlugin\Model\IndexableInterface. The IndexableAwareTrait provides a default implementation that uses the entity id as the document identifier:
<?php
declare(strict_types=1);
namespace App\Entity\Product;
use Doctrine\ORM\Mapping as ORM;
use Setono\SyliusMeilisearchPlugin\Model\IndexableAwareTrait;
use Setono\SyliusMeilisearchPlugin\Model\IndexableInterface;
use Sylius\Component\Core\Model\Product as BaseProduct;
#[ORM\Entity]
#[ORM\Table(name: 'sylius_product')]
class Product extends BaseProduct implements IndexableInterface
{
use IndexableAwareTrait;
}Tip: The document identifier must be unique across the whole index. If you mix entity types (e.g. products and taxons) in one index, override
getDocumentIdentifier()to prefix the id.
The plugin ships a Synonym resource, so create and run a migration:
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate -nphp bin/console assets:installphp bin/console setono:sylius-meilisearch:index # index everything
php bin/console setono:sylius-meilisearch:index --wait # ...and wait for Meilisearch to finish processingAfter the initial population, the plugin keeps the index up to date as entities change — including related changes such as channel prices and variant stock (via tagged IndexableEntityResolver services).
Read this before going to production — it covers how indexing actually runs, when index settings are pushed, and a deploy checklist.
Out of the box every save of an indexable entity indexes synchronously inside the request: the plugin dispatches its messages on a dedicated setono_sylius_meilisearch.command_bus, which — with no transport routing — runs the handlers inline, issuing one blocking Meilisearch HTTP call per index scope. That makes admin product saves slower the more channels/locales/currencies you have. (A failed index update never breaks the save — the plugin catches and logs it — but it does add latency.)
Route the plugin's messages to an async transport instead. All plugin messages implement Setono\SyliusMeilisearchPlugin\Message\Command\CommandInterface, so a single routing entry covers everything:
# config/packages/messenger.yaml
framework:
messenger:
transports:
setono_sylius_meilisearch: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
'Setono\SyliusMeilisearchPlugin\Message\Command\CommandInterface': setono_sylius_meilisearchThen run a worker (supervised, e.g. with Supervisor or systemd) and configure a failure transport:
php bin/console messenger:consume setono_sylius_meilisearch --time-limit=3600Meilisearch index settings — filterableAttributes (from #[Facetable]), sortableAttributes (from #[Sortable]), searchableAttributes (from #[Searchable]), synonyms, etc. — are pushed only by the full setono:sylius-meilisearch:index command. Incremental Doctrine-event indexing updates documents, never settings.
After changing document attributes (adding/removing a
#[Facetable],#[Sortable],#[Searchable], changing a priority, …) runsetono:sylius-meilisearch:index. Waiting for auto-indexing leaves the new facet/sort silently non-functional because its setting was never applied.
- Run the index command on deploy so document/settings changes take effect:
php bin/console setono:sylius-meilisearch:index --wait. - Reindex on a schedule. Incremental indexing keeps documents fresh under normal operation, but a nightly reindex guarantees the index converges with the database (e.g. after bulk SQL / fixtures that bypass Doctrine events). One line in cron:
(
0 3 * * * php /path/to/app/bin/console setono:sylius-meilisearch:index --wait--waitmakes the command block until Meilisearch has finished processing this run's tasks.) - Supervise the worker(s) running
messenger:consume(see above) and monitor the failure transport. - Environment variables:
MEILISEARCH_URLandMEILISEARCH_MASTER_KEYare required;MEILISEARCH_SEARCH_KEYis required for search/autocomplete;MEILISEARCH_PUBLIC_URLandMEILISEARCH_PREFIXare optional.
The plugin's test application config is the best worked example of a multi-index setup (a product index and a custom taxons index).
A document is a plain PHP class describing what a record in a Meilisearch index looks like. The bundled Setono\SyliusMeilisearchPlugin\Document\Product is a good starting point — extend it or create your own by extending Document. Index behaviour is declared with PHP attributes on properties (and even getters):
use Setono\SyliusMeilisearchPlugin\Document\Attribute\Facetable;
use Setono\SyliusMeilisearchPlugin\Document\Attribute\Filterable;
use Setono\SyliusMeilisearchPlugin\Document\Attribute\Image;
use Setono\SyliusMeilisearchPlugin\Document\Attribute\MapProductAttribute;
use Setono\SyliusMeilisearchPlugin\Document\Attribute\MapProductOption;
use Setono\SyliusMeilisearchPlugin\Document\Attribute\Searchable;
use Setono\SyliusMeilisearchPlugin\Document\Attribute\Sortable;
class Product extends Document
{
#[Searchable] // full-text searchable in Meilisearch
public ?string $name = null;
#[Facetable] // shown as a facet/filter on the search page
#[Sortable(direction: Sortable::ASC)] // offered as a sort option
public ?float $price = null;
#[Filterable] // filterable in queries, but not shown as a facet
public array $taxonCodes = [];
#[Image(filterSet: 'sylius_shop_product_thumbnail')] // resolved through Liip Imagine
public ?string $image = null;
#[MapProductAttribute('brand')] // populated from a Sylius product attribute
public ?string $brand = null;
#[MapProductOption('size')] // populated from a Sylius product option
public array $size = [];
#[Facetable] // getters work too — great for computed facets
public function isOnSale(): bool
{
return null !== $this->originalPrice && null !== $this->price && $this->price < $this->originalPrice;
}
}Point the config at your subclass.
indexes.<name>.documentmust reference your document class, not the built-inSetono\SyliusMeilisearchPlugin\Document\Product. It's easy to add a property to a subclass that is never indexed because the config still points at the base document.
Data mappers move data from your entities onto the document. Implement Setono\SyliusMeilisearchPlugin\DataMapper\DataMapperInterface and the service is picked up automatically through autoconfiguration. The plugin ships mappers for the basics (name, url, image, prices, taxons, product attributes/options, popularity).
There are two hooks, and you can combine them:
1. At the database level (most efficient) — listen to QueryBuilderForDataProvisionCreated and restrict the query:
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use Setono\SyliusMeilisearchPlugin\Event\QueryBuilderForDataProvisionCreated;
use Sylius\Component\Resource\Model\ToggleableInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
final class FilterDisabledEntitiesSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
QueryBuilderForDataProvisionCreated::class => 'filter',
];
}
public function filter(QueryBuilderForDataProvisionCreated $event): void
{
if (!is_a($event->entity, ToggleableInterface::class, true)) {
return;
}
$queryBuilder = $event->getQueryBuilder();
$alias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere($alias . '.enabled = true');
}
}2. Per entity during indexing — implement FilterableInterface:
class Product extends BaseProduct implements IndexableInterface, FilterableInterface
{
use IndexableAwareTrait;
public function filter(): bool
{
return $this->isEnabled();
}
}The plugin also ships default entity filters (e.g. an enabled filter for ToggleableInterface entities and a channel filter for channel-aware entities). Toggle them per index under indexes.<name>.default_filters, or add your own by implementing Filter\Entity\EntityFilterInterface.
Incremental indexing reacts to changes on the indexed entity and its translations. To also reindex when an associated entity changes (a custom brand entity, a supplier, …), implement Setono\SyliusMeilisearchPlugin\Resolver\Indexable\IndexableEntityResolverInterface and tag it with setono_sylius_meilisearch.indexable_entity_resolver (autoconfiguration adds the tag). It receives every changed object and returns the indexable entities that should be reindexed. The plugin ships resolvers for ChannelPricing → product (price changes) and ProductVariant → product (stock changes).
Indexing something other than products is the plugin's flagship extensibility story. Here is the whole path, mirroring the built-in taxon index (which the test application configures end to end).
1. Configure the index. Add it under indexes, pointing at a document class and the entity/entities that feed it:
setono_sylius_meilisearch:
indexes:
taxons:
document: 'Setono\SyliusMeilisearchPlugin\Document\Taxon'
entities: [ 'App\Entity\Taxonomy\Taxon' ]2. Make the entity indexable. Implement IndexableInterface on the entity (the IndexableAwareTrait gives you the default id-based document identifier), exactly as for products.
3. Create a document (or reuse the shipped Document\Taxon) with the attributes describing the record — see The document.
4. Provide a URL generator so search results link somewhere. Implement Setono\SyliusMeilisearchPlugin\UrlGenerator\EntityUrlGeneratorInterface (extend AbstractEntityUrlGenerator for the injected router) and tag it with setono_sylius_meilisearch.url_generator (autoconfigured):
final class TaxonUrlGenerator extends AbstractEntityUrlGenerator
{
public function generate(IndexableInterface $entity, array $context = []): string
{
Assert::true($this->supports($entity, $context));
// build the URL for $entity (e.g. via $this->router->generate(...))
}
public function supports(IndexableInterface $entity, array $context = []): bool
{
return $entity instanceof TaxonInterface;
}
}5. (Optional) Provide a scope provider. If your index doesn't need the full channel/locale/currency matrix that products use, implement Provider\IndexScope\IndexScopeProviderInterface and tag it with setono_sylius_meilisearch.index_scope_provider; otherwise the DefaultIndexScopeProvider is used.
Then reindex (setono:sylius-meilisearch:index) and your entity is searchable with clickable results.
Facets are rendered by tagged Setono\SyliusMeilisearchPlugin\Form\Builder\FilterFormBuilderInterface services. The shipped builders cover checkbox (boolean), multi-choice (array) and range (numeric) facets. To render a facet differently, implement the interface and tag the service with setono_sylius_meilisearch.filter_form_builder (autoconfigured). supports() decides whether your builder handles a given facet; build() adds the form child (named after the facet). Builders are tried in tag-priority order, so give a more specific builder a higher priority than the shipped ones.
The search widget is automatically injected into the shop header via the sylius.shop.layout.header.grid Sylius UI event. If your theme doesn't render that event — or you want the widget somewhere else — render it manually:
{{ ssm_search_widget() }}Depending on your configuration it renders either the javascript based autocomplete widget or a plain search form, and it outputs nothing when both search and autocomplete are disabled.
Enable the instant-search widget and point it at one or more indexes:
setono_sylius_meilisearch:
autocomplete:
enabled: true
indexes: [ products ]
container: '#autocomplete' # CSS selector for the element the widget mounts on
limit: 5 # max suggestions to fetch per sourceThe widget talks to Meilisearch directly from the browser using the (read-only) MEILISEARCH_SEARCH_KEY, so make sure that key is a search-only key.
Warning
The search key is embedded in the public page source of every shop page and the browser queries Meilisearch directly with it. Never set MEILISEARCH_SEARCH_KEY to the master key — that would publish full read/write/key-management access to the world. The plugin guards against this: with autocomplete enabled, the container fails to compile when the search key resolves to the same non-empty value as the master key. Ideally, scope the search key to exactly the indexes used by autocomplete (Meilisearch key indexes patterns + actions: ["search"]), since anything it can reach is fully queryable — including fields hidden in the UI (prices etc. are extractable via facetStats/filters even when excluded from displayedAttributes).
Because the browser connects to Meilisearch directly, it needs a URL it can actually reach. In containerized setups (Docker Swarm/Kubernetes) MEILISEARCH_URL is often an internal hostname like http://meilisearch:7700 that browsers can't resolve. Configure the public, browser-facing URL separately in that case:
setono_sylius_meilisearch:
server:
public_url: '%env(MEILISEARCH_PUBLIC_URL)%' # falls back to server.url when null/emptyThe server-side indexing and search keep using server.url; only the autocomplete widget uses server.public_url.
Each autocomplete source is resolved by Meilisearch\Autocomplete\SourceResolverInterface. The default resolver renders @SetonoSyliusMeilisearchPlugin/autocomplete/templates/{indexName}/item.html.twig when that template exists, and otherwise falls back to the shared @SetonoSyliusMeilisearchPlugin/autocomplete/templates/item.html.twig. So with multiple indexes (e.g. products and taxons) you can give each its own template just by creating templates/taxons/item.html.twig in your app. Decorate the resolver if you need to control the urlAttribute (or anything else) per index.
The plugin ships two first-party scripts, both served as plain browser JavaScript (no build step). You customize them by defining a global options object before the script runs — put the <script> that sets it above the plugin's scripts, or in a javascripts block that renders earlier. The plugin's scripts are loaded with defer, so a normal inline <script> in <head> or the body runs first.
Set window.ssmSearch to override any option. Your values are merged over the defaults (the loader object is merged one level deep, so you can override just show or just hide):
<script>
window.ssmSearch = {
form: '#search-form', // CSS selector of the search form (must be a selector, not an element)
contentSelector: '#search-form', // the markup replaced on each search
loader: {
selector: '#ssm-overlay',
show(selector) { document.querySelector(selector).style.display = 'block'; },
hide(selector) { document.querySelector(selector).style.display = 'none'; },
},
// Callbacks receive the field that changed (onSubmit receives nothing); `this` is the manager.
onFilterChange(field) { /* ... */ },
onPageChange(field) { /* ... */ },
onSortChange(field) { /* ... */ },
onSubmit() { this.submit(); },
};
</script>Note:
formmust be a selector string, not a DOM element — the form node is replaced on every search, so a stored element reference would go stale.
The created instance is exposed as window.ssmSearchManager, with a small public API:
window.ssmSearchManager.form— the current results<form>, ornullwhen the "no results" block is shown.window.ssmSearchManager.submit()— runs the background search immediately; returns aPromise.
As the form is used, it emits these bubbling events (all fire on the changed field / new content):
| Event | When |
|---|---|
search:form-changed |
any filter/page/sort field changes |
search:filter-changed |
a filter field changes |
search:page-changed |
the page field changes |
search:sort-changed |
the sort field changes |
search:content-updated |
the results markup was swapped in (AJAX or back/forward) |
Use search:content-updated to re-initialize your own widgets after the results are replaced:
<script>
document.addEventListener('search:content-updated', (event) => {
// event.detail.content is the freshly inserted element
myLazyImages.observe(event.detail.content);
});
</script>Set window.ssmAutocomplete to override any autocomplete-js option. Your options win over the plugin's:
<script>
window.ssmAutocomplete = {
placeholder: 'Search the shop…',
// Supplying your own getSources replaces the default Meilisearch sources entirely.
// getSources({ query }) { return [...]; },
};
</script>Content Security Policy: the default item templates are compiled with
new Function, which requiresscript-src 'unsafe-eval'. If your CSP forbids that, supply your owngetSourcesviawindow.ssmAutocomplete— that path never compiles a template, so no'unsafe-eval'is needed.
Synonyms are managed in the Sylius admin (the plugin adds its own menu section) and synced to Meilisearch automatically when created, updated, or removed. A synonym can be scoped to specific channels and a locale.
git clone git@github.com:Setono/sylius-meilisearch-plugin.git
cd sylius-meilisearch-plugin
composer installQuality checks (all enforced by CI, against both lowest and highest dependencies on PHP 8.1–8.3):
composer analyse # PHPStan (level max)
composer check-style # ECS (fix with: composer fix-style)
composer phpunit # Unit test suite — needs no external services
vendor/bin/rector process --dry-runThe Functional suite needs MySQL/MariaDB and a Meilisearch instance on localhost:7700 (master key aSampleMasterKey, matching the defaults in tests/Application/.env). Start Meilisearch with the bundled compose file:
cd tests/Application && docker compose up -d --wait # `docker compose down` resets it to a clean stateWithout Docker, tests/Application/meilisearch.sh downloads and runs the Meilisearch binary instead.
Then set up the test application and run the suite:
cd tests/Application
export APP_ENV=test
bin/console doctrine:database:create
bin/console doctrine:schema:create
bin/console sylius:fixtures:load -n
bin/console setono:sylius-meilisearch:index --wait
cd ../..
vendor/bin/phpunit --testsuite Functionalcd tests/Application
yarn install && yarn build # Node 20, see .nvmrc
bin/console assets:install
bin/console doctrine:database:create
bin/console doctrine:schema:create
bin/console sylius:fixtures:load -n
bin/console setono:sylius-meilisearch:index --wait
symfony serveThe Playwright suite drives the shop search page and the autocomplete widget in a real browser. It needs the same MySQL/MariaDB + Meilisearch + fixtures + index setup as the Functional suite, plus built assets and the Symfony CLI:
cd tests/Application
docker compose up -d --wait
yarn install && yarn build
bin/console assets:install # APP_ENV=test for all console commands
bin/console doctrine:database:create && bin/console doctrine:schema:create
bin/console sylius:fixtures:load -n
bin/console setono:sylius-meilisearch:index --wait
npx playwright install chromium # first time only
yarn e2e # or: yarn e2e:uiyarn e2e starts the app itself via e2e/serve.sh (which runs symfony serve on 127.0.0.1:8080 with APP_ENV=test and resolves the Meilisearch search key the autocomplete widget needs), so you don't start a server yourself.
src/Resources/public/js/ contains two vendored third-party bundles (their file headers record the exact version, source, and reproduction command):
algolia.autocomplete.js—@algolia/autocomplete-jsUMD production build, downloaded from jsDelivr.meilisearch.autocomplete.js—@meilisearch/autocomplete-client. This package ships ES modules only, so the vendored file is a single self-contained browser IIFE produced by bundling it once with esbuild. That is a one-off step at update time — no build tooling is added to the plugin or to consuming applications; only the produced file is committed.
To update one, follow the command in its header (bump the version), overwrite everything below the banner comment, and run the end-to-end suite. autocomplete.js resolves the Meilisearch client's global name defensively, so a version that renames it still works.
See CLAUDE.md for a deeper tour of the architecture and conventions.