setono / sylius-completeness-plugin
Sylius plugin that computes a weighted, per-channel/per-locale enrichment completeness percentage for products.
Package info
github.com/Setono/sylius-completeness-plugin
Type:sylius-plugin
pkg:composer/setono/sylius-completeness-plugin
Fund package maintenance!
Requires
- php: >=8.1
- ext-mbstring: *
- doctrine/collections: ^1.6 || ^2.0
- doctrine/dbal: ^3.0
- doctrine/orm: ^2.14
- doctrine/persistence: ^2.1 || ^3.0
- psr/clock: ^1.0
- psr/event-dispatcher: ^1.0
- sylius/core: ^1.0
- sylius/core-bundle: ^1.0
- sylius/grid-bundle: ^1.12
- sylius/locale: ^1.0
- sylius/product: ^1.0
- sylius/product-bundle: ^1.0
- sylius/registry: ^1.0
- sylius/resource-bundle: ^1.6
- sylius/taxonomy-bundle: ^1.0
- sylius/ui-bundle: ^1.0
- symfony/clock: ^6.4
- symfony/config: ^6.4
- symfony/console: ^6.4
- symfony/dependency-injection: ^6.4
- symfony/expression-language: ^6.4
- symfony/form: ^6.4
- symfony/http-foundation: ^6.4
- symfony/http-kernel: ^6.4
- symfony/lock: ^6.4
- symfony/messenger: ^6.4
- symfony/options-resolver: ^6.4
- symfony/routing: ^6.4
- symfony/security-csrf: ^6.4
- symfony/service-contracts: ^2.5 || ^3.0
- symfony/string: ^6.4
- symfony/validator: ^6.4
- twig/twig: ^2.14 || ^3.0
Requires (Dev)
- api-platform/core: ^2.7.16
- babdev/pagerfanta-bundle: ^3.8
- behat/behat: ^3.14
- doctrine/doctrine-bundle: ^2.11
- jms/serializer-bundle: ^4.2
- lexik/jwt-authentication-bundle: ^2.17
- phpstan/phpstan-symfony: ^2.0.15
- setono/sylius-plugin-pack: ~1.14.1
- shipmonk/composer-dependency-analyser: ^1.6
- sylius-labs/polyfill-symfony-security: ^1.1.2
- sylius/sylius: ^1.14
- symfony/browser-kit: ^6.4
- symfony/css-selector: ^6.4
- symfony/debug-bundle: ^6.4
- symfony/dotenv: ^6.4
- symfony/intl: ^6.4
- symfony/property-info: ^6.4
- symfony/serializer: ^6.4
- symfony/web-profiler-bundle: ^6.4
- symfony/webpack-encore-bundle: ^1.17.2
- willdurand/negotiation: ^3.1
This package is auto-updated.
Last update: 2026-07-04 20:05:19 UTC
README
Compute a weighted, per-channel/per-locale enrichment completeness percentage for your Sylius products, persist it, roll it up to a single global score on the product and surface it across the admin: a Completeness dashboard (catalog-wide figures, score distribution and the products most in need of work) reached from a single admin menu item, a grid column (threshold color-coded, stale-aware) with a numeric range filter, a channel × locale breakdown panel on the product show and edit pages, a rule CRUD and a "test against a product" preview with a live expression scratchpad.
Scoring rules are database-backed and admin-managed, with three tiers of flexibility:
- Curated checkers — discoverable built-ins (
has_image,has_price,has_minimum_images, …). - Developer checkers — implement an interface, tag the service, done.
- ExpressionLanguage rules — authored entirely in the UI, with a rich helper library.
Installation
1. Require the plugin
composer require setono/sylius-completeness-plugin
2. Register the bundle
# config/bundles.php return [ // ... Setono\SyliusCompletenessPlugin\SetonoSyliusCompletenessPlugin::class => ['all' => true], ];
3. Import the configuration and routing
# config/packages/setono_sylius_completeness.yaml imports: - { resource: "@SetonoSyliusCompletenessPlugin/Resources/config/app/config.yaml" } setono_sylius_completeness: ~
# config/routes/setono_sylius_completeness.yaml setono_sylius_completeness_admin: resource: "@SetonoSyliusCompletenessPlugin/Resources/config/routes/admin.yaml" prefix: /admin
4. Make your Product completeness-aware
Apply the shipped interface and trait to your product entity:
# src/Entity/Product/Product.php namespace App\Entity\Product; use Doctrine\ORM\Mapping as ORM; use Setono\SyliusCompletenessPlugin\Model\ProductCompletenessAwareInterface; use Setono\SyliusCompletenessPlugin\Model\ProductCompletenessAwareTrait; use Sylius\Component\Core\Model\Product as BaseProduct; #[ORM\Entity] #[ORM\Table(name: 'sylius_product')] class Product extends BaseProduct implements ProductCompletenessAwareInterface { use ProductCompletenessAwareTrait; }
The plugin ships XML Doctrine mappings, so add the matching mapping fragment for the fields the trait introduces (or use PHP attributes as above and only map the association + scalar columns). Example XML fragment:
<!-- config/doctrine/Product.orm.xml --> <entity name="App\Entity\Product\Product" table="sylius_product"> <indexes> <index columns="completeness_ratio"/> <index columns="completeness_dirty_at"/> </indexes> <field name="completenessRatio" column="completeness_ratio" type="smallint" nullable="true"/> <field name="completenessRubricVersion" column="completeness_rubric_version" type="integer" nullable="true"/> <field name="completenessDirtyAt" column="completeness_dirty_at" type="datetime_immutable" nullable="true"/> <one-to-many field="completenesses" target-entity="Setono\SyliusCompletenessPlugin\Model\ProductCompleteness" mapped-by="product" orphan-removal="true"> <cascade> <cascade-persist/> <cascade-remove/> </cascade> </one-to-many> </entity>
Point your host application to the completeness Product model:
# config/packages/_sylius.yaml sylius_product: resources: product: classes: model: App\Entity\Product\Product
5. Update the database
Generate and run a migration (the plugin does not ship migrations because the target sylius_product
table is host-owned):
bin/console doctrine:migrations:diff bin/console doctrine:migrations:migrate
This creates the setono_sylius_completeness__* tables and adds completeness_ratio (indexed),
completeness_rubric_version and completeness_dirty_at (indexed) to sylius_product.
6. Schedule the drain (required)
Most recalculation happens in the background: changes mark the affected products dirty and a drain command recalculates them. Run it on a cron every few minutes:
*/5 * * * * cd /path/to/app && bin/console setono:completeness:process
It is safe to overlap (a leased lock guarantees a single run) and to run as often as you like. See How recalculation is triggered for the full picture.
7. Optional: starter ruleset + initial calculation
bin/console sylius:fixtures:load setono_sylius_completeness # a sensible starter rubric bin/console setono:completeness:recalculate --all # score the whole catalog
Concepts
- Rule — a persisted, admin-managed record binding a checker
type(+configuration) to a weight tier (low/medium/high/critical), an optional scope (channels/locales/taxons) and an optional ExpressionLanguage condition gate. Theexpressionchecker'sconfigurationholds the ExpressionLanguage expression that is the check itself. The set of enabled rules is the scoring rubric. - Weight vs score — a rule's weight is "how much it matters" (from the tier); a checker's score is
"how met it is" (0.0–1.0). Binary checkers return 1.0/0.0; graded checkers grant partial credit (e.g.
has_minimum_imageswith 3 of 5 ⇒ 0.6). - Context — a
(channel, locale)pair. Each context is scored independently; translatable fields resolve to exactly that locale (a missing translation reads as empty, never the default-locale text). - N/A — a context with no applicable rules is not scored (rendered as "—"), distinct from a measured 0%. N/A contexts are excluded from the global rollup.
- Context settings — an optional per-
(channel, locale)record holding a "ready" threshold (for color-coding) and a rollup weight (0 = excluded from the global score). A missing row means defaults, so an empty table reproduces flat-average, single-threshold behavior. - Rollup — the per-context ratios collapse into the single
completenessRatiovia a configurable strategy (weighted_averagedefault,minimum,default_channel), after dropping N/A and excluded contexts. - Staleness — a monotonic rubric version is bumped on every rule change and stamped on products at
calc time. The grid and panel show a "recalculating…" marker for products whose stamped version is
behind (a rule changed) or that are flagged
completeness_dirty_at(their own data changed), until the drain catches up.
How recalculation is triggered
Scores are kept up to date through three lanes, so an interactive edit is instant while bulk changes never block a request:
| Change | Mechanism | When it recalculates |
|---|---|---|
| A product/variant is saved in the admin | Sylius resource events (sylius.product.post_*, sylius.product_variant.post_*) |
Immediately & synchronously — the fresh score is on the page you land on |
| Any other product change — API, imports, programmatic writes, changes to related entities | a Doctrine onFlush listener sets completeness_dirty_at on the affected product(s) |
on the next drain |
| A rule changes | the rubric version is bumped (every product becomes stale) | on the next drain |
| A context changes | a rollup-only refresh is dispatched over Messenger | when that message is handled |
Manual — the dashboard/grid "Recalculate" buttons, or setono:completeness:recalculate |
Messenger / direct | on demand |
The drain (setono:completeness:process, step 6) is the workhorse: every few minutes it recalculates
the products that are dirty or stale, in id-keyset chunks, under a Symfony Lock (a DoctrineDbalStore on
your default connection) so runs never overlap and a crashed run self-heals. It debounces bursts (a
10k-product import is one drain, not 10k recalculations) and needs no message worker. The
completeness_dirty_at flag is cleared only if it hasn't changed since the product was picked up, so an
edit that lands mid-run is retried rather than lost.
The lock store lazily creates a
lock_keystable on first use. In a multi-server production setup you may prefer to create it up front — see the Symfony Lock docs.
The Doctrine onFlush marker never calls flush() or dispatches — it writes the flag as part of the same
flush via recomputeSingleEntityChangeSet. New products are not flagged: their null rubric version already
makes them drain candidates. To watch an additional entity, register an
AffectedProductsResolverInterface — both the marker and the immediate lane use it.
Set recalculate_on_doctrine_flush: false to disable the dirty-marking entirely (then only the manual
lane and a periodic recalculate --all keep scores fresh).
Expression authoring
Conditions and expressions use the Symfony ExpressionLanguage. A condition decides if a rule applies; an
expression is the check for expression-type rules (a boolean means met/not met, a number between 0 and 1
grants partial credit).
The condition and expression fields (and the preview scratchpad) are enhanced with a CodeMirror editor that adds syntax highlighting and autocompletion of the in-scope variables and the registered functions (host-added functions included). CodeMirror is loaded from a versioned, SRI-pinned CDN, so the plugin needs no asset build; if it is unavailable the fields degrade gracefully to plain textareas.
Variables in scope: product, channel, locale, channelCode, localeCode.
Translatable fields are read through the product getters and always resolve to the scored locale:
word_count(product.getDescription()) >= 200
Use native operators — arithmetic + - * / %, comparison == != < > <= >=, logical and or not, membership
in / not in, regex matches, concat ~, ternary ?: — plus the helper library (word_count,
char_count, has_attribute, attribute_value, image_count, in_taxon, has_price, price, min,
max, between, …). The full catalog is rendered inline in the rule form and on the preview screen.
"Required-when" rules use both slots — e.g. "if type is beer, beer_type must be set":
- condition:
attribute_value(product, 'type') == 'beer' - expression:
has_attribute(product, 'beer_type')
The rule then vanishes for non-beer products (counting toward neither numerator nor denominator).
Caveats:
- Regex ReDoS: author-supplied
matchespatterns run unsandboxed; a catastrophic pattern can hang a calculation. Keep rule administration to trusted users. - Select-attribute values are codes:
attribute_value(product, code)returns the stored option code, not the display label. - The case-insensitive contains helper is named
icontains(containsis a reserved EL operator).
Extension points
Everything is a tagged service. All of these are supported and documented:
| Tag / interface | Purpose |
|---|---|
setono_sylius_completeness.checker (CompletenessCheckerInterface) |
Add a checker. If two share a type, the last registered wins — that's how you override a built-in. |
setono_sylius_completeness.checker_configuration_form_type |
Register a checker's configuration form. |
setono_sylius_completeness.expression_function_provider |
Add expression helper functions (a Symfony ExpressionFunctionProviderInterface). |
setono_sylius_completeness.affected_products_resolver (AffectedProductsResolverInterface) |
Make changes to your own entities trigger recalculation — no core change. |
setono_sylius_completeness.rollup_strategy (RollupStrategyInterface) |
Add a rollup strategy. |
The public API is Setono\SyliusCompletenessPlugin\Calculator\CompletenessCalculatorInterface (a pure dry-run
that returns the full breakdown) and Setono\SyliusCompletenessPlugin\Updater\ProductCompletenessUpdaterInterface
(calculate + persist). After each persisted calculation a ProductCompletenessCalculated event is dispatched
(with a bulk flag). Notice that context changes trigger a rollup-only refresh, which recomputes the
global ratio from existing rows and does not dispatch that event.
Configuration reference
setono_sylius_completeness: rollup_strategy: weighted_average # weighted_average | minimum | default_channel | <custom> default_channel_code: ~ # channel used by the default_channel strategy default_ready_threshold: 80 # green/"ready" line when a context has no override amber_band: 20 # width of the amber zone below the threshold (0 disables amber) weight_tiers: low: 1 medium: 3 high: 6 critical: 10 enable_custom_weight: false # exposes the advanced per-rule float override recalculate_on_doctrine_flush: true # a flush marks affected products dirty for the drain recalculation_lock_ttl: 900 # lease (s) of the drain's lock; refreshed every chunk
There is intentionally no watched_entities key: the set of watched classes is derived from the registered
AffectedProductsResolverInterface services. To watch an additional entity, register a resolver.
Console
# The background drain: recalculate dirty/stale products (schedule this on a cron, see step 6) bin/console setono:completeness:process # Recalculate the whole catalog synchronously (good after install, or as a periodic safety net) bin/console setono:completeness:recalculate --all # Recalculate specific products bin/console setono:completeness:recalculate --product=SKU-1 --product=SKU-2
Translations
en is the authoritative source of truth. The plugin ships admin translations for da, sv, no, fi,
de, fr, es, it, nl, pl, pt, cs, hu, ro and uk; any untranslated key falls back to
English via the Symfony translator. (Norwegian uses no; if your shop runs nb, copy the catalog under that
code.)