A voice-of-customer capture tool for the search results page (SRP): an authorized storefront customer can file a free-text ticket about a set of search results — tied to the query term, active filters, page number, and the SKUs actually shown on that page, plus a topic — and Zed back-office admins hold a conversation about it from there. The Yves side is write-only: once a ticket is submitted there is no way to read it, or any reply, back from the storefront. Everything past submission happens in Zed.
Part of the Search Relevance project.
Not an official Spryker project.
spryker-community/*is an independent, community-built package namespace with no affiliation to, sponsorship by, or endorsement from Spryker Systems GmbH. The name describes what these packages are (community contributions for Spryker Commerce OS), not who maintains them. The matching Packagist namespace is held by an unrelated GitHub organization, which is why installation goes through a VCS repository entry rather than a plaincomposer require— see Installation.
- Why a separate package
- Status
- What it does
- Requirements
- Installation
- Limitations
- Testing and CI
- License
Structurally close to spryker-community/search-ranking-optimizer's
SRP relevance-rating widget (Yves widget → Gateway-controller write → Zed persistence), but a different
bounded context: this is qualitative support/VoC ticketing, not a numeric ranking signal. It has no hard
dependency on search-ranking/search-ranking-optimizer's tuning machinery — the optional integration
that lets a ticket's frozen snapshot also carry search-ranking's specificity-weighting result is a soft
suggest-only coupling (see Installation) that additionally only produces anything once
search-ranking's specificity weighting itself is turned on (off by default there — see its own README's
step 14c), the same shape search-ranking itself already uses toward search-debug. Kept standalone so a
shop can install it without any of those.
Feature-complete for its scope. Verified: 134 Codeception tests (Client, Zed, Zed GUI and Yves layers plus
both browser Presentation suites, including real-database integration coverage for the full submit → reply
→ status-change → list/find round trip), phpcs clean, and the package's public SearchFeedbackFacade at
100% method coverage. See Testing and CI below for the measured numbers and the
(deliberate, documented) gaps.
- Yves: a plain HTML form on the SRP (topic dropdown + free-text body), visible only to a customer
holding
SubmitSearchFeedbackTicketPermissionPlugin. Submitting redirects back to the SRP with a flash message — no AJAX, no new frontend component. - Zed: a ticket grid + per-ticket conversation thread. Any Zed admin with access to the module can view every ticket and reply; changing a ticket's status (open/answered/closed) is its own controller action so it can be independently restricted to a "ticket worker" ACL group, leaving a "feedback admin" group able to view and reply but not triage. There is no per-submitter row-level scoping — Zed users and Yves customers are separate identity systems with no built-in link, so both Zed roles see the same full list. The grid has an optional Store/Locale filter (two dropdowns, "all" by default — this is a cross-scope triage view, not a per-market page); the ticket detail page shows the customer's email (resolved from their customer reference, cached per page load) instead of the raw reference, and a "View search results" link that reconstructs the exact SRP the ticket was filed from, in either dynamic-store-mode configuration.
- Frozen replay. Ranking isn't stable over time in a typical shop — nightly randomized tie-breaking,
regularly-refreshed business scores, an optimizer retuning formula weights — so re-running the same query
later often shows a different page than the one a ticket was filed about. When the optional wiring in
Installation is registered, this package captures the raw Elasticsearch response (and,
if
spryker-community/search-rankingis installed AND has specificity weighting turned on — off by default, see its README's step 14c — its specificity-weighting result) at the moment a ticket is filed, and the "View search results" link replays that exact frozen response instead of running a live search — while still rendering it through today's live template/formatter code, so only the ranking data itself is frozen. A ticket filed before this feature existed, or on a shop that hasn't wired it, falls back to a live search exactly as before — this is additive, never a hard requirement.
The SRP ticket form (rendered below the product grid, outside the filter form — see step 5), the Zed ticket grid (List of Tickets, sortable/searchable via DataTables), and a ticket's detail page (context + full conversation thread + reply form + status actions):
- PHP >= 8.3
- Spryker (kernel/gui/acl/customer/company-user/store/permission-extension/propel-orm/transfer/user/
zed-request — see
composer.jsonfor floors, verified bycomposer check-floors) - Elasticsearch/OpenSearch, via
spryker/search-elasticsearch. Unlike earlier versions of this package, it now also captures the raw search response for a ticket's frozen replay (see What it does) — but it still never issues its own query. The only place this package talks to the search engine at all is reconstructing a previously-captured response from stored data (Elastica\Response/Query/ResultSet\DefaultBuilder), never a live call. Propel/MySQL remains the only real datastore. - B2B company-user accounts.
CompanyUserPermissionAuthorizerresolves "does this customer actually holdSubmitSearchFeedbackTicketPermissionPlugin" via their activeCompanyUser, the same permission-granting mechanism the rest of a B2B shop already uses (same posture as the siblingsearch-ranking-optimizerpackage's own rating-permission check). A B2C-only shop with noCompanyUsermodule has nothing to grant the permission to.
Verified on OpenSearch 1.3.4, 2.11, 3.5.0 and Elasticsearch 8.11. This package issues no live query
and reads no engine-version-specific response shape — it only reconstructs a previously-captured
Elastica\Response from stored data — so the engine version is not load-bearing here. The OpenSearch 3.5
upgrade needed no code change in this package; see
Migrating to OpenSearch 3.x for why the frozen-replay reconstruction is
engine-version-agnostic, plus the one upgrade-time schema trap every Spryker shop hits.
-
This package lives in Spryker's community GitHub org at
github.com/spryker-community/search-feedback. It is not yet published on Packagist under thespryker-communityvendor namespace, so until that lands, install from a VCS repository:"repositories": [ { "type": "vcs", "url": "https://github.com/spryker-community/search-feedback" } ]
composer require spryker-community/search-feedback:^1.4
-
Register the
SprykerCommunitycore namespace: add it toKernelConstants::CORE_NAMESPACESinconfig/Shared/config_default.php. Spryker'sClassResolveronly ever looks in the project namespace plus whatever's listed here — miss this and every class in the package fails to resolve, most visibly asCan not resolveSearchFeedbackFacadein Business layer for your moduleSearchFeedback`` the moment anything tries to use the facade, even though composer installed the package correctly and every DependencyProvider below is wired up right.$config[KernelConstants::CORE_NAMESPACES] = [ // ... existing entries 'SprykerCommunity', ];
-
Register
Pyz\Client\SearchFeedback\SearchFeedbackDependencyProvider,Pyz\Yves\SearchFeedbackWidget\SearchFeedbackWidgetDependencyProvider,Pyz\Zed\SearchFeedback\SearchFeedbackDependencyProvider, andPyz\Zed\SearchFeedbackGui\SearchFeedbackGuiDependencyProvideras project-level overrides of the package's own. -
Register
SubmitSearchFeedbackTicketPermissionPluginin both the Client and ZedPermissionDependencyProvider::getPermissionPlugins(), and grant it to whichever company role should see the SRP ticket form (viacompany_role_permission.csvor the Company Role GUI). -
Register
SearchFeedbackWidgetRouteProviderPluginin the YvesRouterDependencyProviderandSearchFeedbackWidgetTwigPluginin the YvesTwigDependencyProvider. -
Include the ticket-form view in your SRP template, gated on
canSubmitSearchFeedbackTicket(). It must render OUTSIDE any enclosing<form>your SRP template already has (e.g. the catalog page's own filter/sort/pagination form). HTML doesn't allow nested forms — the browser silently drops the inner one, and clicking submit posts the OUTER form instead (wrong endpoint, wrong fields, no CSRF validation). Confirmed live: this exact breakage, when the include first landed inside that form.Building
submitUrlunderSPRYKER_DYNAMIC_STORE_MODE=1. If your shop runs with dynamic store mode enabled, don't passpath('search-feedback-widget/submit-ticket')straight into the molecule'ssubmitUrl— route generation fails for any non-default store withRouteNotFoundException: None of the chained routers were able to generate route: 'search-feedback-widget/submit-ticket' not found, even though the exact same route matched fine on the way in.StorePrefixRouterEnhancerPlugin::afterMatch()only returns matched request attributes; it never callsRequestContext::setParameter('store', ...), so generation has no store to work with for any route this package (or any other community package) registers. Build the URL by hand from the current request instead:{% set requestStore = app.request.attributes.get('store') %} {% set storePrefix = (requestStore is not empty and app.request.getPathInfo() starts with ('/' ~ requestStore ~ '/')) ? ('/' ~ requestStore) : '' %} {% include molecule('search-feedback-ticket-form', 'SearchFeedbackWidget') with { data: { canSubmit: canSubmitSearchFeedbackTicket(), searchTerm: data.searchString, pageNumber: data.pagination.currentPage | default(1), skuList: data.products | default([]) | map((product) => product.abstract_sku), csrfToken: searchFeedbackTicketCsrfToken(), submitUrl: storePrefix ~ '/search-feedback-widget/submit-ticket', topics: getSearchFeedbackTicketTopics(), snapshotToken: data.searchFeedbackSnapshot.token | default(''), }, } only %}Verified against
/DE/...,/AT/..., and no-prefix requests — all resolve to the correctactionURL. Shops that don't run dynamic store mode can usepath()directly and skip this. -
Copy the
<search-feedback-gui>block from this package'sCommunication/navigation.xmlinto your project'sconfig/Zed/navigation.xml. If your project already lists every top-level Zed nav group explicitly (rather than relying on Spryker's default full-merge, i.e.ZedNavigationConfig:: getMergeStrategy()returnsBREADCRUMB_MERGE_STRATEGY), a brand-new top-level group silently never renders — nest the block as a<pages>entry inside a top-level group instead, either an existing one (e.g.merchandising, next to the siblingsearch-preferencesentry) or a new project-owned one you create yourself (e.g. a sharedsearch-toolboxcategory grouping this package withspryker-community/search-index-alias— see that package's own README and this project'sconfig/Zed/navigation.xmlfor a worked example). In that case don't also copy the package's own<search-feedback-tickets>/<search-feedback-ticket-detail>children into your root file — leave your root entry childless and let them merge in automatically from the package's ownnavigation.xml. Redeclaring them yourself causesarray_merge_recursiveto collide on the duplicate scalar leaves (same key, same string value) and turn them into arrays, which crashes the page withTwig\Error\RuntimeError: ... ("Array to string conversion") in "@Gui/Partials/navigation.twig". Then runconsole navigation:cache:remove+console navigation:build-cacheto pick up the change — the Zed nav tree is cached and does not re-readnavigation.xmlon every request. -
Warm the Zed Backoffice router cache:
console router:cache:warm-up:backoffice. Zed's nav renderer drops any item whose navigation-XML key isn't found in the cached Backoffice route collection (BackofficeNavigationItemCollectionRouterFilter) — with a stale cache the "Search Feedback" entry from step 7 is silently missing from the sidebar (no error, no log, it just isn't there) even though the page itself is reachable by typing the URL directly. Easy to miss because every other Zed page keeps working; only a newly-added bundle's own nav entry is affected. -
Run
console transfer:generate,console propel:diff+console propel:migrate(notpropel:sql:insert— that reapplies the full schema dump),console propel:model:build, andconsole dev:ide-auto-completion:generate.propel:model:buildis easy to skip since neitherdiffnormigratebuilds PHP classes, only the database schema — missing it surfaces asClass "Orm\Zed\SearchFeedback\Persistence\SpySearchFeedbackTicketQuery" not foundthe first time anything touches the ticket table. -
Warm up the Zed BackendGateway router:
console router:cache:warm-up:backend-gateway. It's a separate cache from the Backoffice router's (step 8) — every other page can work fine while ticket submission alone 404s (No route found for "POST .../search-feedback/gateway/submit-ticket") until this runs. Same gotcha the siblingsearch-ranking-optimizerpackage's own Gateway controller has. Also re-warm the Yves router cache —yves router:cache:warm-up— since step 5 adds a new Yves route; skipping it renders the SRP with aRuntimeError: None of the chained routers were able to generate route: Route 'search-feedback-widget/submit-ticket' not foundthe moment the ticket-form include tries to build itssubmitUrl. Standard practice for any package that adds a Yves route, not unique to this one, but easy to forget mid-walkthrough since nothing points at it explicitly. -
Frozen replay wiring (optional but recommended — without it, "View SRP" on the Zed ticket detail page just re-runs a live search, which is the exact drift this feature exists to close; see What it does):
- Register
SearchFeedbackSnapshotResultFormatterPluginin the ClientCatalogDependencyProvider'sCATALOG_SEARCH_RESULT_FORMATTER_PLUGINSlist. This alone is not enough if your SRP twig template whitelists which controller-returned fields it forwards intodata— SprykerShop's ownCatalogPagesearch template does exactly that ({% define data = {...} %}inTheme/default/views/search/search.twig, reading from_view.*), and a project that copied/extended that template for other community packages (search-debug'ssearchDebugTokens, search-ranking'srandomImpactIsActive, …) has to add this package's key the same way:Confirmed live: without this line the plugin still runs and captures correctly every time (a session entry always appears), butsearchFeedbackSnapshot: _view.searchFeedbackSnapshot | default,
data.searchFeedbackSnapshot.tokenis silently empty in the ticket-form include below — the hiddensnapshotTokenfield never renders, every ticket saves with zero snapshot rows, and nothing anywhere errors. This is the single most notorious silent-failure point in this whole step; ifsearch-feedback:check-installation's frozen-replay section is green but tickets still have no snapshot, check this template mapping first. - Register
SearchFeedbackReplayContextEventDispatcherPluginin the YvesEventDispatcherDependencyProvider::getEventDispatcherPlugins(). - Register
ViewSearchFeedbackTicketReplayPermissionPluginin both the Client and ZedPermissionDependencyProvider::getPermissionPlugins(), and grant it to whichever company role should be able to review a replay — same mechanism as step 4, a separate, independently-grantable permission. A brand-new permission plugin needs one extra step the Company Role GUI won't do for you: Spryker only knows about a permission plugin once it's been synced intospy_permission, and that sync is not automatic on deploy. Visit/permission/index/syncin Zed once (or click "Sync permissions" under Maintenance in the sidebar) after registering the plugin. Skipping this doesn't just hide the permission from the grant checkbox — it makes the Company Role edit/create page throw a hardErrorException: Undefined array key "ViewSearchFeedbackTicketReplayPermissionPlugin"for every company role, not just ones that would hold this permission, because that page always evaluates every registered permission plugin againstspy_permission. Confirmed live. This is a generic Spryker gotcha, not specific to this package, but it bites hard the first time a project adds its first community-package permission plugin. Once synced, this package'sdata/glossary.csvalready ships thepermission.name.ViewSearchFeedbackTicketReplayPermissionPluginlabel the Company Role GUI needs to render the checkbox (samepermission.name.*conventionSubmitSearchFeedbackTicketPermissionPluginuses, step 4) — re-runvendor/bin/console data:import glossary(step 13) if that page instead throwsMissingTranslationException: Could not find a translation for key permission.name..... - Add a project-level
Pyz\Client\SearchElasticsearch\SearchElasticsearchFactoryoverridingcreateSearchClient()to wrap the realSearchinReplayCapableSearch— this is the seam that actually swaps a live ES call for the frozen snapshot; see the class's own docblock. First Factory-level (not just DependencyProvider-level) override this package needs — a new pattern if your project hasn't overridden a vendor Client Factory before, but a small one:use Spryker\Client\Kernel\Locator; // NOT Spryker\Shared\Kernel\Locator — that class doesn't exist. // Locator is a separate concrete class per layer; only the // interface it implements is shared. Since this file is Client- // layer code, this is the Locator to use. class SearchElasticsearchFactory extends SprykerSearchElasticsearchFactory { public function createSearchClient(): SearchInterface { return new ReplayCapableSearch( parent::createSearchClient(), Locator::getInstance()->searchFeedback()->client(), Locator::getInstance()->customer()->client(), ); } }
- Optional, only if
spryker-community/search-rankingis also installed: registerSprykerCommunity\Client\SearchRanking\Plugin\SearchFeedback\SearchFeedbackTermVectorSnapshotProviderPluginin your project'sPyz\Client\SearchFeedback\SearchFeedbackDependencyProvider::getTermVectorSnapshotProviderPlugins()override, so a ticket's snapshot also carries the specificity-weighting result that scored it. That result is only ever non-null once search-ranking's specificity weighting is turned on too — it's off by default there, a project-level override ofPyz\Client\SearchRanking\SearchRankingConfig::isSpecificityWeightingEnabled()(see search-ranking's README, step 14c). Registering this plugin without that flag is harmless, just a no-op: every snapshot'shasTermVectorSnapshotflag staysfalse.
- Register
-
In the Zed ACL module, create your "ticket worker" and "feedback admin" groups and grant/deny access to
SearchFeedbackGui/Detail/changeStatusaccordingly — this package ships no ACL fixture data. -
Translations. Two separate mechanisms, one per layer — Zed's
transfilter does not read from the Yves-facing Glossary module, same split as the siblingsearch-ranking/search-ranking-optimizerpackages:- Zed GUI (ticket list/detail, reply form, status labels): ships as
spryker/translatorCSV catalogs underdata/translation/Zed/. If your project already extendedPyz\Zed\Translator\TranslatorConfig::getCoreTranslationFilePathPatterns()with thespryker-community/*glob for a sibling package, this package is auto-discovered by the same glob — no extra step. Otherwise add it once:$coreTranslationFilePathPatterns[] = APPLICATION_VENDOR_DIR . '/spryker-community/*/data/translation/Zed/[a-z][a-z]_[A-Z][A-Z].csv';
- Yves widget (ticket form + the check-installation page below): a plain
data/glossary.csv, imported the normal Spryker way (the same Redis-backed Glossary module every Yves-facing string in a Spryker shop already uses):vendor/bin/console data:import glossary
- Zed GUI (ticket list/detail, reply form, status labels): ships as
-
Verify the installation.
vendor/bin/console search-feedback:check-installation
Most of the steps above fail silently when missed — the ticket form simply never appears, or appears but 404s on submit, with nothing in any log to say why. This command checks the core namespace registration, that every plugin class is loadable, and that the ticket table is reachable (a real DB round trip — the fastest way to notice step 9 was skipped). It exits non-zero and names the remedy for whatever is wrong, and explicitly flags the Backend Gateway router cache (step 10) — the single most notorious silent-failure point, since every other Zed page keeps working while ticket submission alone 404s until it's warmed.
It also reports whether anybody other than a root-style admin can reach this package's Zed pages. Zed access is deny-by-default outside a matching ACL rule, and a nav entry the current user has no rule for is filtered out of the sidebar entirely rather than 403ing — so on a shop with real restricted back-office roles, "nobody adjusted ACL" looks exactly like "the package was never installed". A default Spryker install needs nothing done here (
root_roleholds a total wildcard), which is why this is a warning at most, never a failure, and only when restricted roles exist and not one of them has a rule for this package's module. Restricting these pages to root-style admins is a perfectly ordinary choice; the command cannot know which roles you meant to grant, so it asks you to confirm rather than telling you to fix.It is explicit about its own blind spots: running in Zed, it never bootstraps the Yves DI container, so it cannot confirm the route/Twig plugins from step 5 or the template include from step 6 — it says so in its output.
It also reports on frozen replay (step 11, optional): it checks the three package classes that step ships (
ReplayCapableSearch,SearchFeedbackSnapshotResultFormatterPlugin,ViewSearchFeedbackTicketReplayPermissionPlugin) load, and — the one project-level piece it CAN see from Zed — thatsrc/Pyz/Client/SearchElasticsearch/SearchElasticsearchFactory.phpexists and actually wrapsSearchinReplayCapableSearch. Since the whole step is optional, a missing override is a warning, not a failure: skip it if you intentionally don't use frozen replay. The other two step-11 registrations (the ClientCATALOG_SEARCH_RESULT_FORMATTER_PLUGINSentry and both Permission DependencyProvider entries) are DI wiring this command cannot introspect any more than it can any other DependencyProvider registration in this file; the YvesEventDispatcherDependencyProviderentry for that same step is covered by the Yves counterpart below instead.Three more checks, added after real bugs surfaced during this feature's first live end-to-end verification:
- Snapshot column types. A real DB round trip confirming
raw_response/query_dsl/request_parameters/term_vector_snapshotonspy_search_feedback_ticket_srp_snapshotare actuallyLONGTEXT, not plainTEXT. Catches a project that installed this package from before theLONGVARCHAR→CLOBschema fix and never re-migrated — a real captured Elasticsearch response routinely exceedsTEXT's 64KB cap, and the truncation only surfaces as a 500 on ticket submission, not at install time. Warning, not a failure — same "optional feature" posture as the rest of step 11. - Permission sync. Confirms
SubmitSearchFeedbackTicketPermissionPluginandViewSearchFeedbackTicketReplayPermissionPluginare actually synced intospy_permission(a real DB lookup, not just class-loadable). A permission plugin being registered inPermissionDependencyProviderdoes not mean Spryker knows about it — that needs a one-time/permission/index/syncvisit in Zed (see step 11 above), and skipping it doesn't just hide the grant checkbox, it throws a hard error on the Company Role create/edit page for every role, not just ones that would hold the permission. Confirmed live. - Search-results template mapping, on the Yves counterpart below: confirms the project's
src/Pyz/Yves/CatalogPage/Theme/default/views/search/search.twigactually mapssearchFeedbackSnapshot: _view.searchFeedbackSnapshotintodata— the single most notorious silent-failure point in step 11, confirmed live (see step 11 above for the full explanation).
Register it in
src/Pyz/Zed/Console/ConsoleDependencyProvider.php:use SprykerCommunity\Zed\SearchFeedback\Communication\Console\SearchFeedbackCheckInstallationConsole; protected function getConsoleCommands(Container $container): array { return [ // ... existing commands new SearchFeedbackCheckInstallationConsole(), ]; }
Yves-side counterpart.
/search-feedback-widget/check-installationcloses exactly the gap the console command names above — it runs from inside the real Yves DI container (no new plugin registration needed, it uses the sameSearchFeedbackWidgetRouteProviderPluginfrom step 5), and checks the three Twig functions and the submit-ticket route from step 5, plus (optional, step 11) whetherSearchFeedbackReplayContextEventDispatcherPluginis registered as aKernelEvents::REQUESTlistener — miss it and a replay link is never gated by the view-replay permission at the Yves layer (the Zed gateway still re-checks authorization independently, so this is a UX gap, not a security hole). It also checks whetherViewSearchFeedbackTicketReplayPermissionPluginis registered on the Client, and whether the optional search-ranking specificity integration is wired up and enabled. And it checks whether the project's search-results template actually mapssearchFeedbackSnapshotintodata(see step 11 above) — the single most notorious silent-failure point in the whole frozen-replay feature, confirmed live: without it, the formatter still captures correctly every time, but the ticket form's hiddensnapshotTokenfield silently stays empty and no ticket ever gets a frozen-replay snapshot. It is complementary, not a replacement: it does not re-check the core namespace, plugin class loadability, or the ticket table — run the console command for those.Reachable only when BOTH hold:
- The route exists at all — governed by
SprykerCommunity\Shared\SearchFeedback\SearchFeedbackConstants::IS_CHECK_INSTALLATION_PAGE_ENABLED, which defaults to disabled, same posture and same rationale as the identical flag on the siblingsearch-debugpackage. Enable it in your development-tier config:$config[SearchFeedbackConstants::IS_CHECK_INSTALLATION_PAGE_ENABLED] = true;
- The visiting customer holds the
SubmitSearchFeedbackTicketPermissionPluginpermission — checked wherever the flag above leaves the route enabled. Missing the permission there renders a dedicated explanation with the exact remedy (grant the permission, per step 4) at HTTP 403, rather than a bare access-denied response.
- Snapshot column types. A real DB round trip confirming
spryker-community/search-feedback exposes one api-platform-era storefront REST resource,
search-feedback-tickets — a POST-only endpoint for filing a new SRP feedback ticket. This project's
Glue layer runs on spryker/api-platform (schema-driven: resources/api/storefront/*.resource.yml +
.validation.yml, autowired Provider/Processor classes, generated #[ApiResource] PHP), not the
legacy ResourceRoutePluginInterface/@Glue(...) convention. Nothing beyond declaring the schema is
needed in a host shop — it is discovered automatically as long as the shop's
config/Glue/packages/spryker_api_platform.php includes vendor/spryker-community in
sourceDirectories() (already the case for any shop that installs community packages under that vendor
namespace).
If your shop installs spryker-community/* packages via composer path repositories (symlinked into
vendor/spryker-community/*, rather than a real composer require install): a plain sourceDirectories
entry is not actually sufficient on its own — spryker/api-platform's schema finder does not follow
symlinked directories, so this package's schema is silently invisible to api:generate despite the
correct config. See
spryker-community/search-debug's own README,
"Glue API" section, for the fix (a small SchemaFinder/ValidationSchemaFinder override).
Requires a bearer token for an authenticated customer (securityBearerAuthRequired: true,
security: "is_granted('ROLE_CUSTOMER')"). customerReference/storeName/localeName are always
resolved server-side from the authenticated session/store context — never taken from the request body.
Real authorization (SubmitSearchFeedbackTicketPermissionPlugin) is re-checked, and enforced, entirely
server-side in Zed's GatewayController — exactly the same posture the Yves SubmitTicketController
already relies on. The Glue processor performs the same permission check inline via
PermissionClientInterface::can() before calling the Client, purely as a UX-level fast-fail (a 422 with
a specific message beats a round-trip that Zed would reject anyway); it is not a security boundary and
cannot be trusted as one.
Request body (JSON:API attributes):
| Field | Type | Required | Notes |
|---|---|---|---|
topic |
string | yes | Short subject line. |
body |
string | yes | The customer's message. |
searchTerm |
string | no | The search term the ticket is filed against. |
filters |
array | no | Active facet/filter state, e.g. {"category": ["123"]}. |
pageNumber |
integer | no | Result page the ticket was filed from. |
skuList |
array | no | SKUs shown on that page. |
Response, on success (201): the same fields echoed back, plus id (the created ticket's id, as a
string), status, and createdAt. body is echoed from the request — SearchFeedbackTicketTransfer
has no top-level body field; the persisted message text lives at messages[0].body in the ticket's
thread, which this endpoint does not otherwise expose.
Errors: 422 on validation failure, on "not logged in," on "not authorized," or on any
isSuccess: false response from SearchFeedbackClient::submitTicket() (its errorMessage becomes the
JSON:API error detail).
After changing either .resource.yml file, regenerate with vendor/bin/glue api:generate from the
host shop root.
- No notification when a ticket is answered. There is no email/mail integration anywhere in this package — a Zed admin's reply lands in the database and nowhere else. Combined with the Yves side being write-only (see above), a customer who filed a ticket has no way to ever learn it was answered unless told through some other channel. Deliberate scope: adding notifications means picking a channel (email? a storefront inbox widget, which would also mean building the read-back path this package intentionally doesn't have?) that's a real product decision, not a default this package should assume.
- No per-submitter scoping in Zed. Any Zed admin with access to the module sees every ticket from every customer — Zed users and Yves customers are separate identity systems with no built-in link, so there's no natural "your tickets" boundary to enforce even if it were wanted. Access control here is role-level (ticket worker vs. feedback admin, via the two separately-restrictable controller actions), not row-level.
- The same is true of a replay, on the storefront side.
ViewSearchFeedbackTicketReplayPermissionPlugingates "can this customer replay a ticket's frozen SRP at all," not "does this specific ticket belong to them or their company" — a customer holding the permission can replay any ticket by id, including another customer's. Deliberate, confirmed choice, not an oversight: same posture as the Zed-side point above, extended to a Yves-granted permission instead of a Zed ACL role. GrantViewSearchFeedbackTicketReplayPermissionPluginaccordingly — treat it as "can see any customer's search context," not as a personal, self-scoped permission. - One flat conversation thread per ticket, no internal/private notes. Every message on a ticket — customer or Zed admin — is visible to any Zed admin who can view the ticket. There's no way for one Zed admin to leave a note for another without the customer's original message context, since there's no customer-facing view to accidentally leak an internal note into anyway; the constraint here is purely "everyone with access sees everything," not a security boundary between Zed users.
- A frozen snapshot can go stale relative to today's product/index data. The captured
_sourceis exactly what Elasticsearch returned at ticket-filing time; if a field gets renamed or restructured by a later reindex, replaying an old ticket runs that old-shaped data through today's formatters, which can silently show a missing badge/facet rather than error. Accepted trade-off, not a bug — the alternative (migrating every stored snapshot forward on every reindex) is far more machinery than this feature warrants. - A pending snapshot can be silently evicted before a ticket is submitted.
SearchFeedbackSnapshotContextstages a captured snapshot in session storage, capped at 5 pending entries (FIFO eviction) to keep a long browsing session's storage bounded. Only a short-lived, random token — never the captured response/query/termvector data itself — is embedded as a hidden field in the ticket form; the browser never sees the actual snapshot, so it cannot be forged. Five or more searches — including opening "View SRP" replays, which are searches too — between seeing the results you want to complain about and clicking Submit will silently evict the session-side snapshot that token points to; the ticket still submits, just without a frozen replay (hasTermVectorSnapshot: false), with nothing telling the customer or the Zed admin that happened. - The stored
queryDslfield can go stale for a different reason. It's captured for informational display only, never replayed —Spryker\Client\SearchElasticsearch\Search\Search::executeQuery()never passes Elastica's$optionsthrough, so anything out-of-band a future ranking strategy might add (search_pipeline, a neural rerank pass) would be invisible to it. Not a concern for replay itself, since replay only ever uses the raw response.
.github/workflows/ci.yml runs on every push and pull request:
| check | what it protects |
|---|---|
composer validate |
the manifest stays well-formed |
phpcs (PHP 8.3, 8.4) |
coding standard, via this package's own phpcs.xml |
composer check-floors (PHP 8.3, 8.4) |
the declared dependency floors are real |
rector dry-run (PHP 8.3, 8.4) |
no unapplied Rector rule set drifts in |
phpmd (phpmd.xml + phpmd-public-methods.xml) |
cyclomatic/NPath complexity, method/class length stay reasonable — run as two separate invocations because PHPMD merges every loaded ruleset's exclude-pattern into one global file list per run, and only the public-method-count rule should skip Facades/Factories |
phpstan (PHP 8.3, 8.4) |
static analysis, level 8, standalone CI variant — see "Static analysis" below |
portable tests (PHP 8.3, 8.4) |
this package's own @group Portable test subset actually passes — see "Test suite" below |
Same check-floors rationale as the sibling search-debug/search-ranking packages: this package's
require constraints are a promise about which Spryker versions an adopter may install, which a full demo
shop's dependency tree cannot itself verify. composer check-floors resolves every constraint to its
oldest allowed version and asserts every vendor symbol src/ references still exists there.
Every test class carries a portability @group, so codecept run -g <tag> tells you what a given test
actually needs:
| tag | needs | where it runs |
|---|---|---|
Portable |
nothing beyond Generated\Shared\Transfer\* |
standalone — CI runs exactly this, see below |
NeedsDatabase |
a real Propel connection | host shop only |
NeedsProject |
Codeception's project-only actor/module stack, or this package's own installation diagnostics — see their own docblocks | host shop only |
This package never touches Elasticsearch/OpenSearch at all, so unlike its sibling packages there is no
NeedsSearch tag here.
Portable tests run standalone in CI on every push, via tests/codeception.portable.yml +
tests/_ci-standalone/ — no host shop, no live database. The recipe: a direct TransferBusinessFactory
call generates Generated\Shared\Transfer\* into src/Generated/ (gitignored, exactly like a real project
already gitignores its own — regenerated every run), bypassing the full Zed Console/Kernel bootstrap and
Locator entirely. Run it yourself the same way CI does:
composer install
php tests/_ci-standalone/generate-transfers.php
vendor/bin/codecept run -c tests/codeception.portable.yml -g PortableThe rest of the suite — NeedsDatabase/NeedsProject — ships under tests/SprykerCommunityTest/, one
per layer, and runs inside a host shop (they use the host's test bootstrap and, for the Zed suites, a
live Propel/MySQL connection):
vendor/bin/codecept build -c vendor/spryker-community/search-feedback/tests/SprykerCommunityTest/Client/SearchFeedback
vendor/bin/codecept run -c vendor/spryker-community/search-feedback/tests/SprykerCommunityTest/Client/SearchFeedback
vendor/bin/codecept run -c vendor/spryker-community/search-feedback/tests/SprykerCommunityTest/Zed/SearchFeedback
vendor/bin/codecept run -c vendor/spryker-community/search-feedback/tests/SprykerCommunityTest/Zed/SearchFeedbackGui
vendor/bin/codecept run -c vendor/spryker-community/search-feedback/tests/SprykerCommunityTest/Yves/SearchFeedbackWidget106 tests in this table, plus 28 more in the two browser Presentation suites below (134 total), all green:
| layer | tests | notable coverage |
|---|---|---|
| Client | 19 | SearchFeedbackClient, SearchFeedbackFactory, SearchFeedbackStub, SearchFeedbackConfig, both permission plugins — 100% methods; plus ReplayCapableSearch's fall-through/replay decision tree and SearchFeedbackSnapshotContext's capture/consume/eviction behavior (frozen replay, see What it does) |
Zed (SearchFeedback) |
47 | SearchFeedbackFacade 100% (6/6), TicketManager 100%, SearchFeedbackEntityManager/Repository/Mapper 100%, CompanyUserPermissionAuthorizer 100%, GatewayController 100%, SearchFeedbackCheckInstallationConsole 100% (every check's pass/fail branch, via a mocked Facade + CommandTester) |
Zed (SearchFeedbackGui) |
25 | ReplyForm validation (via a real Symfony FormFactory), SearchFeedbackGuiCommunicationFactory DI wiring (all 7 get*()/create*() methods), TicketTable::configure()/resolveCustomerEmail(), DetailController::resolveCustomerEmail()/buildSearchResultsPageUrl() (both dynamic-store-mode branches, against this shop's real config), IndexController::resolveStoreName()/resolveLocaleName() |
Yves (SearchFeedbackWidget) |
15 | SearchFeedbackWidgetFactory DI wiring, CheckInstallationController 100% (permission gate, both Twig-function/route check branches, against a hand-built ContainerInterface fixture — no real app boot needed) |
The Zed suite's GatewayControllerTest and SearchFeedbackFacadeTest are real database integration
tests — no mocked Propel query builder — covering the full submit → reply → status-change →
list/find round trip through the actual Locator-resolved Facade, the same path DetailController and
IndexController drive in production. CompanyUserPermissionAuthorizerTest and GatewayControllerTest
together prove the authorization gate actually blocks unauthorized writes (persists nothing), not just
that it decorates the response — mirroring the equivalent tests in the sibling
search-ranking-optimizer package, which this module's own CompanyUserPermissionAuthorizer is a direct,
deliberate copy of.
Several classes are not exercised beyond a DI-wiring smoke test, and this is a structural limitation of testing Communication/Yves-layer classes outside a live HTTP request — not an oversight:
SearchFeedbackGuiCommunicationFactory::createReplyForm(). It resolves the real Zed Silexform.factoryapplication service, which only exists once the full Zed app is bootstrapped — confirmed empirically (Call to a member function create() on nullunder Codeception'sEnvironmenthelper alone). TheReplyFormtype it builds has full, dedicated coverage inReplyFormTestvia a real, standalone SymfonyFormFactoryinstead.TicketTable::render()/fetchData()/formatStatus()/prepareData(). These resolve the request and Twig environment from the Zed application container the same waygetFormFactory()does — same limitation, same reason no sibling package (search-debug,search-ranking,search-ranking-optimizer) tests aTableclass's full render path either.configure()andresolveCustomerEmail()(the rest of its real logic — header/sortable/searchable config, the store/locale URL-param baking, the customer-email N+1 lookup and its not-found fallback) have full, dedicated coverage instead, driven directly via Reflection (seeTicketTableTest). The Twig-dependent remainder is verified live via the Presentation suite'sTicketGridAndDetailCest/StoreLocaleFilterCest: status badge CSS class mapping, the per-row "View" action link, and the Store/Locale filter dropdowns all render and round-trip correctly.DetailController::indexAction()/IndexController::indexAction()/tableAction()/changeStatusAction(). Same limitation for the same reason — they resolvecreateReplyForm()/createTicketTable()->render()/fetchData(). Their non-framework-coupled logic (resolveCustomerEmail(),buildSearchResultsPageUrl(),resolveStoreName(),resolveLocaleName()) is unit tested directly instead (seeDetailControllerTest/IndexControllerTest); the full action methods are covered end-to-end by the Presentation suite.SubmitTicketController(Yves). Reaches throughPermissionAwareTrait::can()(Spryker's globalLocatorsingleton, no constructor seam to substitute a fake permission client) and the flash-message helpers onAbstractController, both of which need a bootstrapped Yves Silex app — the exact same documented limitation the siblingsearch-debugpackage accepts for its ownSearchDebugContextEventDispatcherPlugin::handleRequest()permission-granted branch. The controller's own request-parsing helper (buildRedirectParameters()) and its collaborators (SearchFeedbackClient,SearchFeedbackWidgetFactory) are covered independently.- Frozen-replay capture/delivery path:
SearchFeedbackSnapshotResultFormatterPlugin(needs a realElastica\ResultSetfrom a live search to exercise meaningfully —ReplayCapableSearch, the class that actually consumes a captured snapshot, has full coverage instead, see the table above),SearchFeedbackReplayContextEventDispatcherPlugin(samePermissionAwareTrait/Silex-app limitation asSubmitTicketControllerabove), andGatewayController::getTicketSrpSnapshotAction()/SearchFeedbackEntityManager::createTicket()'s snapshot-persistence branch (would need the same real-database integration styleGatewayControllerTest/SearchFeedbackFacadeTestalready use for the rest of the ticket lifecycle — not yet added). Verified manually end-to-end in a live shop instead (real Propel migration applied,phpstan/phpcsclean against every new file) — see the package's PR for the verification log.
Static analysis (phpstan, level 8) runs in two variants:
composer phpstan-ci(configphpstan.ci.neon) — what CI runs on every push, standalone. Same transfer-generation recipe as thePortabletest subset above, and treats two categories of class as out of scope rather than faking them: Propel's generatedOrm\Zed\*\Persistence\*entity/query/map classes (need a real schema + database, viapropel:model:build) and the aggregatedGenerated\{Zed,Yves,Client,Service}\Ide\AutoCompletionstub (an aggregate across every module in a real project's full dependency graph, viaconsole dev:ide-auto-completion:generate). Both gaps are the same shape as the sibling packages' checked-inPageIndexMap.phpfixture — a single package can't reproduce a project-wide generator's output standalone.composer phpstan(configphpstan.neon) — the full check, run from a host shop, where those generated classes are real. This is the one that actually type-checks persistence-layer and DI-wiring code against their real generated types, so it stays the authoritative check for adopters even though CI can't run it:
vendor/bin/console dev:ide-auto-completion:generate
vendor/bin/phpstan clear-result-cache -c vendor/spryker-community/search-feedback/phpstan.neon
vendor/bin/phpstan analyse -c vendor/spryker-community/search-feedback/phpstan.neon vendor/spryker-community/search-feedback/srcThis suite is a development tool for this package's own reference demoshop — it is not something to install or run against YOUR shop. It logs in as a real Zed user and as
search-admin@test-company.example(Yves, the one account this demoshop's fixtures grantSubmitSearchFeedbackTicketPermissionPluginto), submits and replies to real tickets against this demoshop's seeded catalog and store/locale scope. Point it at a different shop and most of it will simply fail on missing data, not on a real defect. It exists to catch UI regressions while developing this package, not as something adopters are expected to run.
Reproducing the fixture on a fresh clone of this demoshop. spencor.hopkin@acme.com
(customer_reference DE--1) is already a base-fixture member of the test-company company with no
company-role assignment — that's the negative-test account, nothing to add. The permitted account
(search-admin@test-company.example) is not a base fixture; add it to
data/import/common/common/:
customer.csv:SearchAdmin--1,en_US,,search-admin@test-company.example,Mr,Search,Admin,,Male,,$2y$12$CUw8PyVm4isuM.ugzQhZ0.os.n1nlGJOA61SEd7cgjXivzt5LqJ2.,2026-08-10(that hash ischange123, the password the Yves Tester expects)company_user.csv:SearchAdmin--1,SearchAdmin--1,test-company,truecompany_business_unit_user.csv:SearchAdmin--1,test-business-unit-1company_user_role.csv:test-company_Admin,SearchAdmin--1company_role_permission.csv: bothtest-company_Admin,SubmitSearchFeedbackTicketPermissionPlugin,andtest-company_Admin,ViewSearchFeedbackTicketReplayPermissionPlugin,
Then re-import: vendor/bin/console data:import customer company-user company-business-unit-user company-user-role company-role-permission. The Zed suite's login (amZed()) uses this demoshop's
standard backoffice test-authentication helper and needs no extra fixture.
Two suites, split by layer:
tests/SprykerCommunityTest/Zed/SearchFeedbackGuiPresentation/— the ticket grid, detail page (context table + conversation thread), reply-and-auto-transition rules (a reply moves an Open ticket to Answered, never moves an Answered/Closed one), manual status changes in either direction (self-contained: restores whatever status it found), reply-body escaping (a<script>/&/<b>payload asserted to render as literal text, never executes), edge cases (unknown status value, nonexistent ticket id on both the change-status and detail routes — all redirect gracefully, never crash), the Store/Locale filter dropdowns (selecting a store reloads the grid with the right query string and shows the selection back asselected; the default "all" option applies no filter), and a plain-catalog-search regression check confirming a logged-out guest sees none of this package's or its siblings' UI.tests/SprykerCommunityTest/Yves/SearchFeedbackWidgetPresentation/— the SRP ticket form: a real submission that redirects back with the success flash message, client-side rejection of a blank body, and the permission gate (anonymous guest, logged-in customer without the role, and the permitted customer as the positive control).
vendor/bin/codecept build -c packages/spryker-community/search-feedback/tests/SprykerCommunityTest/Zed/SearchFeedbackGuiPresentation
vendor/bin/codecept run -c packages/spryker-community/search-feedback/tests/SprykerCommunityTest/Zed/SearchFeedbackGuiPresentation
vendor/bin/codecept build -c packages/spryker-community/search-feedback/tests/SprykerCommunityTest/Yves/SearchFeedbackWidgetPresentation
vendor/bin/codecept run -c packages/spryker-community/search-feedback/tests/SprykerCommunityTest/Yves/SearchFeedbackWidgetPresentationLike the rest of the test suite, neither is part of CI — both need a real running shop plus the Selenium/
chromedriver service already provisioned in this demoshop's docker-compose.yml.
MIT — see LICENSE.


