Changelog
All notable changes to StoreSeeder are documented in this file. This is the complete history and
the canonical record — readme.txt carries only the four most recent releases, in
the WordPress plugin directory format, and links back here.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[1.2.0] - 2026-08-02
Section titled “[1.2.0] - 2026-08-02”-
Recipes. A new page below Overview builds a whole shop in one click — a corner grocer, a fashion boutique, a home & garden store — rather than a resource at a time. StoreSeeder could already generate any volume; what it could not do was make two hundred products that look like one business. A recipe is the vocabulary that fixes that: product names, a category tree, brand names, tag labels, a price band and variation axes, applied across nine resources in dependency order. It is words and numbers, never records, so every generator invariant still holds and every existing parameter still works on top of one.
-
Three recipes ship, from storeseeder-recipes, downloaded once behind the consent prompt that already governs the sample data. A separate repository so that adding a shop type — or translating one — needs no plugin release.
-
storeseeder_recipesregisters a recipe from your own plugin with no download at all, andstoreseeder_recipe_directoriessays where its words live.storeseeder_recipes_sourcerepoints the archive at a fork. -
wp storeseeder recipe listandwp storeseeder recipe run <id>, running the same ordered plan through the same endpoints as the admin. -
Undo a whole recipe. Every row a run writes carries a run id in the ledger, so removing one is a single button rather than nine separate purges.
wp storeseeder cleanup --run_id=does the same from the command line. -
Refresh the recipes without waiting for a plugin update. A Refresh button on the Recipes page and a Recipes card in Settings — count, last-updated, Sync now, Force re-sync — so a new shop type or a new locale reaches an installed site the moment the archive has it. The archive was reachable exactly once before this: the only control that fetched it was the empty state’s download button, which made the separate repository’s whole purpose unreachable.
-
GET /storeseeder/v1/recipes/status, read-only, reporting what is on disk without fetching. Andforceon/recipes/sync, which deletes the local copy rather than writing over it. -
Settings is three tabs — this site, your preferences, the plugin — over the scopes the page was already grouped by. It was 4,733px, five and a half screens, with the Danger zone at the very bottom; the worst tab is now about two. The tab is in the URL, so
#/settings?tab=pluginis linkable, and the strip is a real tablist with arrow-key navigation rather than a row of toggle buttons. -
Settings fields flow into columns. Fourteen role toggles occupied fourteen full-width rows, 18% of the entire page; they are a grid now, 867px down to 221px. The width cap moved from the field to the control, so an explanation no longer wraps to four lines with 700px empty beside it.
Changed
Section titled “Changed”-
Downloaded files live under
uploads/storeseeder/—recipes/andsample-data/<archive>/— instead of two directories at the uploads root, one of them named after its repository. Existing installs are moved once, automatically; a site whose filesystem needs credentials it does not have keeps reading the old location rather than silently falling back to placeholder product names.The path is decided in one place now,
StoreSeeder\Storage. It was computed in five, two of which were the same value — the download path and the read path, built independently, which is how 72 of 73 locales came to be generating “Widget” in the first place.sample-data/<archive>/is keyed on the archive rather than the resolved platform, so a platform that ships reference data of its own throughstoreseeder_sample_data_sourceno longer overwrites the shipped set.
- A run of more than 100 items failed outright. The generate endpoint caps
countat 100 in its own schema, and WordPress rejects an over-cap argument during validation — so the admin, which sent whatever the count stepper said, returnedInvalid parameter(s): countand wrote nothing. The stepper meanwhile allowed up to 100,000, offering three orders of magnitude more than the API would take. Counts are split into requests of a hundred now, and the progress bar counts completed requests rather than animating against a guessed duration — it used to reach 100% and stop while the request was still in flight, and to delay the request by the length of its own animation. The recipe runner had always chunked correctly; only the single-run and batch paths had not. - “Add to batch” discarded everything the page was configured with. A queued row carried only a route and a count, so the resource parameters, the seed and the metadata switch were dropped: a products row queued at $200–$900 ran at the schema default. Two queues of the same generator also merged by summing their counts, so queueing cheap products and then expensive ones produced twice as many cheap ones. A queued run is the same run, deferred.
- The topbar’s platform picker listed one store twice and hid Auto. With WooCommerce selected the list read “WooCommerce · Fluent Cart · WooCommerce”, because the Auto entry took its label from whatever was currently selected. Auto was still there and still worked; it was no longer reachable by name. An earlier fix had corrected the half of this that showed while Auto itself was selected.
- Settings sat against the left edge of the measure the other pages fill.
.fp-settings-colcapped itself at 680px without centring, so moving from Overview, Recipes or Our Plugins to Settings shifted every card 220px leftwards and left 440px of empty page — the horizontal jump the shared page measure exists to prevent. - Seventy-two of seventy-three locales were generating “Widget” and “Gadget”.
Generator::get_sample_data_path()built one path and stopped, so any locale without a sample data file fell through to the inline literals each generator carried, with aWP_DEBUG_LOGwarning as the only sign. Vocabulary now falls back toen_US— and when a recipe advertises a locale it does not actually ship, the admin says so before the run instead of quietly serving English. - Categories, tags and brand names were hardcoded constants and could not vary by shop type. They read from vocabulary now, with the constants kept as the offline fallback.
Changed
Section titled “Changed”- The dashboard’s four stat tiles match the generator tiles: same size, radius and accent. The per-card hues implied a distinction the data does not have, and the inline tint had no dark-theme step, so the tiles came out visibly washed out beside the generator grid.
- “Fake data” is “test data” throughout — in the docs, the npm keywords, and the permission error a user actually reads. Fake means counterfeit; these are real rows in real tables.
[1.1.0] - 2026-08-01
Section titled “[1.1.0] - 2026-08-01”- StoreSeeder is no longer a Fluent Cart plugin. Where data lands is now decided by a
platform driver, and the same twenty-one generators feed every driver — a generator names no
platform, so a fixed seed produces identical data wherever it is written. Fluent Cart and
WooCommerce both ship;
storeseeder_platformsis the whole surface needed to add another from a separate plugin. The target is chosen in the topbar and in Settings, stored site-wide so two administrators cannot unknowingly seed different stores, and with more than one platform active the generator page asks rather than guessing — writing rows into the wrong store is not a failure anyone would notice afterwards. - A WooCommerce driver, covering eighteen of the twenty-one resources through the platform’s own
CRUD objects —
WC_Product,WC_Order,WC_Customer,WC_Coupon— rather than direct writes, so generated rows go through the same validation real ones do. Subscriptions need WooCommerce Subscriptions and say so; transactions, labels and licences are reported unsupported with the reason, because WooCommerce has no equivalent and no plugin changes that. - Four new resources: product categories (nested where asked), product tags, brands (optionally nested as sub-brands), and licences. Fluent Cart has categories and brands but no tag taxonomy at all, and refuses tags rather than registering one of its own — terms that were real and unreachable from any Fluent Cart screen would be worse than none.
- A cleanup that works from a ledger, not a guess. Every successful write records
(platform, resource, id)in{prefix}storeseeder_generated, and Settings → Danger zone (orwp storeseeder cleanup delete) walks that list, children before parents, with a per-resource breakdown before anything goes. Nothing is ever matched on for resembling test data: on a staging site restored from production that guess eventually deletes a real catalogue. - Two MCP tools per generator instead of one — a read-only
preview-<resource>besidegenerate-<resource>— with three switches in Settings (surface, preview, generate) applied at registration, so a withdrawn tool is never offered rather than offered-and-refusing. - Every declared parameter now reaches the generator or a writer. Across the eight resources
audited, forty-nine parameters were declared on the admin, the REST schema or the MCP ability and
read by nothing: a price range that never moved a price, an item count that never changed an order,
a guest ratio fixed at 30%, a loyalty tier that ignored the tier asked for. Where a parameter could
not be honoured it was removed rather than left decorative —
order_value_rangeandcart_value_rangecannot be met once line items are priced from the catalogue, andcalculation_methodsnamed three shipping calculations neither platform has. Where three surfaces had drifted into three vocabularies for one idea, they now share one; the names that shipped keep working as synonyms. - Canonical vocabularies for transaction states and types and for cart stages, beside the order
statuses already in
Platforms\Status, each mapped per writer — Fluent Cart spells completedsucceeded, disputeddispute_lost, and an abandoned cartintended. - Capability support is computed per request, never cached, because it is conditional: WooCommerce has no subscriptions until WooCommerce Subscriptions is active, and StoreEngine gates resources behind addons. An unsupported resource reports which plugin would enable it instead of dimming a tile in silence, and the REST API answers 400 rather than reporting success and writing nothing.
GET /platformsandPOST /platforms/targetfor reading the platform matrix and setting the site-wide target.- Seventy-five locales, every one FakerPHP ships a provider for, searchable in the picker by language name or locale code.
- Settings gains a Target platform card — the site-wide option previously reachable only from
a topbar dropdown, naming what
Autoresolves to — and an Appearance card for theme and density. storeseeder_capabilitysets who may use the plugin, governing the admin menu, every REST route, every MCP ability and the AJAX handlers together, so access cannot be widened for one surface and left behind on another.- Filters for the cases where only a per-resource hook existed:
storeseeder_canonical_entityandstoreseeder_rest_paramsapply to all seventeen resources and run before their specific counterparts. Plusstoreseeder_locales,storeseeder_mcp_ability_definition,storeseeder_admin_payloadandstoreseeder_sample_data_source. - The products preview gains a Type column, and it reads the run’s own
product_typeparameter rather than rolling a fresh value — choosedigitaland every previewed row says digital, which is the question the control beside it just asked.mixedis the only setting that varies per row, which is what mixed means. - Fluent Cart Pro support, and a licence generator. The driver now detects Pro, reports it
as an extension of the platform —
[{ slug, label, active, version }], a newPlatform_Driver::extensions()seam that WooCommerce Subscriptions will use the same way — and gates the new licences resource on it. Pro ownsfct_licenses, so without Pro there is nowhere to write one; the capability reports the plugin by name, so the admin can say “install Fluent Cart Pro” and the REST API answers with the same reason instead of failing once per item. The generator makes licences worth testing against: unlimited and limited tiers, some at their activation limit, some expired in the past, some withdrawn while still dated, and activation rows for the sites a licence is live on. Eighteen generators now. - WP-CLI.
wp storeseeder generate <resource>pluspreview,platforms,localesandsample-data, each dispatching through the same REST controller the admin uses — so validation, platform resolution and the capability check are one implementation rather than three. Any parameter the endpoint accepts works as a flag, including lists (--payment_methods=stripe,paypal) and objects (--price_range='{"min":5,"max":500}'); an unknown flag is rejected with the list of parameters that endpoint takes, because--lokale=de_DEsilently generating English is worse than an error. Commands that touch store state require--user: shell access is not the same as consent to write to this site’s tables, and this keeps one answer to “who may generate?” across all four surfaces. - Translations resolve from the plugin’s own
languages/directory, for PHP and for the React admin.load_plugin_textdomain()runs oninit, andwp_set_script_translations()now receives the path — without it core looked only inwp-content/languages/plugins, so a bundled JSON translation, or one Loco Translate wrote beside the plugin, was ignored for every admin string. Loco Translate and WPML String Translation both work without configuration. - The plugin screen clears other plugins’ admin notices — setup wizards, review nags, upgrade
prompts — so the interface starts at the top of the page instead of below a stack of messages
about the rest of the site. Only this screen is affected, and
storeseeder_hide_foreign_admin_noticesturns it off.
Changed
Section titled “Changed”- PHPStan analyses
storeseeder.phpas well, and itsignoreErrorslist went from 42 patterns to 9. Twenty-four were WordPress functions that the WordPress stubs have covered all along, and eighteen more suppressed nothing at all — each one a standing offer to swallow the first future error that happened to match its wording.reportUnmatchedIgnoredErrorsis on now, so a pattern has to keep earning its place; the three that mask genuinely unreachable branches are labelled as findings to revisit rather than left looking like noise. - The REST API, MCP schemas and admin picker all enumerate locales from one list, so what is offered is exactly what generates.
includes/is laid out so the directories state the class hierarchy: each abstract sits at the root of the scope it governs, its concrete children one level beneath. Three registries — platforms, REST controllers, MCP abilities — replace three hardcoded lists of seventeen, each with its own filter.- Density reaches controls, not only containers: buttons, chips, inputs, selects, badges, pills and switches read the same tokens the compact override changes, so their padding and type scale with the boxes around them.
- New brand mark: the icon is a single indigo lit like glass rather than a two-colour gradient ramp, and the WordPress.org banners and screenshots now share that palette from one source.
- Every clickable row, overlay and field caption is reachable by keyboard. Rows that navigate are buttons, the command palette and locale picker are dialogs that close on Escape, the custom select announces itself as a combobox, and field captions are bound to the controls they name.
- Font weights use the standard 400/500/600/700 scale, which a non-variable system font can render as drawn.
- Every generated WooCommerce tax class taxed orders twice. The writer prepended a duplicate of the primary rate to a list that already opened with it, and WooCommerce applies one rate per priority level — so a priority-1 copy sat beside the real row and both applied.
- Generated WooCommerce customers had no billing name, company or email. The address setter read a
namekey that a customer address has never carried; it hasfirst_nameandlast_name. Those fields are on the profile screen and in the admin’s customer list. - Lifetime spend reached Fluent Cart’s
ltvas a float of dollars where the column is a BIGINT of cents, so $1,234.56 was truncated to 1234 and displayed as $12.34.purchase_valuelikewise received a bare number where Fluent Cart’s own migrator writes a per-currency map. - WooCommerce discarded shipping on every generated order.
calculate_totals()sums the order’s shipping lines, so a total set before it was overwritten; shipping is a line item now, and the discount is applied after the totals run for the same reason. - Fluent Cart priced order and cart lines from generated data rather than the variation each line points at, so an order line could read $412 for a product the catalogue sells at $19 — and every revenue figure disagreed with the store it came from.
- Fluent Cart’s coupon
stackablewas hardcoded'no', so no generated coupon could ever combine with another, andstart_datewas never set although its own validation requires one whenever an end date is present. created_atis absent from three Fluent Cart models’ fillable lists, so mass assignment dropped it and Eloquent stamped today: a customer “since 2021” registered this morning, a cart abandoned two weeks ago was abandoned today, and every transaction on an old order settled now.- A SKU-less product variation failed the whole run on Fluent Cart —
fct_product_variationshas a unique index onsku, so the second empty string collided. The column is nullable and MySQL allows any number of NULLs under a unique index. - Every Fluent Cart shipping method went into one “Worldwide Shipping” zone, so a method was
available everywhere whatever coverage was asked for. Its zones take
all, a bare country code, or aselectionwith the country list in meta; all three are used now. - City and postcode silently failed the whole WooCommerce tax-rate insert. They are not columns on
the rate row — they live in
woocommerce_tax_rate_locations— and passing them to_insert_tax_rate()errors with “Unknown column ‘tax_rate_city’” while the response still reports success with zero rates written. - A converted cart pointed at no order and carried no completion date, so it was marked completed and
appeared in no revenue figure. A tax rate’s priority was a counter in generation order, so the first
region listed outranked every other regardless of precision. A tax state was two random letters,
producing codes like “QK” that match nothing at checkout. And
price_variation_range.min_percentagewas capped at a maximum of zero, so an all-positive range — every variation dearer than the base — was rejected as an invalid parameter. - Three writers offered no result filter, and one was named after a resource that no longer
exists. Attributes, logs and refunds returned their result with no hook at all, while the
other fifteen resources had one — so integration code written against “every writer fires
storeseeder_{resource}_generation_result” was wrong for three of eighteen. And the shipping-plan writer still firedstoreseeder_shipping_method_generation_result, left over from before the resource was renamed. The hook name is now derived from the writer’s own resource byWriter::filter_result(), so it cannot drift again; the old shipping hook keeps firing after the correct one, deprecated, so existing callbacks still run. Two tests fail if a writer stops offering the filter or starts hardcoding a name. - Nineteen hooks were undocumented, including every per-resource result filter and the admin filters for notices, menu icon and permalinks. The architecture table is now checked against the source.
jest.config.jswould have shipped in the release zip;.distignorehad not been revisited since Jest arrived.- The WordPress.org screenshots and banners were regenerated, and three pieces of copy in them had gone stale: the frame caption and banner both said “for Fluent Cart” on a plugin that is no longer Fluent-Cart-only, and the banner advertised 17 generators. The banner now names WP-CLI, which is new. The Settings screenshot needed more than a re-shoot: the page grew from four cards to eight across three sections, so the old whole-page capture clipped a card in half — it now captures a frame-shaped viewport with the sidebar collapsed, which is what the settings column wants, and the frames carry the product tagline under the wordmark.
- The recorded test baselines in CLAUDE.md and AGENTS.md were two and five commits stale, which makes them useless for the one thing they are for — noticing that a refactor changed the count.
- The icon set carried
grid, byte-identical todashboard: one glyph under two names, which is the ambiguity the icon pass was meant to remove. Also dropped two dead exports,fetchPlatforms()andtoLabel(). - The locale picker was a lie. The admin offered seventy-three locales while the REST API
accepted six, and FakerPHP falls back to
en_USwithout complaining — so sixty-seven of them silently produced English.pt_AOwas offered with no provider behind it, whilear_EG,ar_JOanden_CAshipped with faker and were never offered. A test now fails if the list and faker’s providers drift apart. - The picker also stored the display label, so the chosen value could never be sent to the API as-is, and the topbar globe wrote a key nothing read — clicking a locale changed the pill and nothing else.
- An unrecognised locale falls back within its own language, preferring that language’s own
country: a
de_LUsite getsde_DErather than the Austrian German that sorted first. - The admin interface is translatable. None of it was, for two independent reasons.
wp i18n make-potdoes not read.tsxsources, so every string in the React admin — “New generation”, “Add to batch”, the generator descriptions, the settings labels — was absent from the translation template; they are now extracted from the compiled bundle, taking it from 347 entries to 632. And the bundle was namedadmin.js, which WP-CLI mistakes for a minified file: it rewrote the reference tobuild/a.js, so translations would have been filed under a name WordPress never looks for. The bundle isadmin-app.jsnow. - The dependency notes on each generator page were passed to the translation function as a variable, so they were never extracted and could not be translated.
- Settings stored in the browser are validated field by field when read, so a value left by an older version can no longer reach the UI as the wrong type.
[1.0.0] - 2026-07-22
Section titled “[1.0.0] - 2026-07-22”Initial release.
- 17 generators, each with a REST controller and an optional MCP tool, all persisting through native Fluent Cart models: Products, Product Variations, Customers, Orders, Transactions, Refunds, Coupons, Shipping Plans, Shipping Classes, Tax Classes, Order Tax Lines, Attributes, Cart Sessions, Labels, Product Downloads, Subscriptions, and Logs.
- Modern single-page React admin (React Router v7, Tailwind CSS v4) with a live preview, command palette, and batch queue.
- REST API — every generator at
storeseeder/v1/<resource>/generate. - Optional Model Context Protocol (MCP) integration via the WordPress Abilities API +
mcp-adapter. - Consent-gated sample data: locale-specific reference data downloads from GitHub only after an administrator accepts a one-time consent prompt (or clicks Sync in Settings); no site data is sent, and declining leaves generators working from built-in defaults. The decision is site-wide and changeable from Settings: Revoke withdraws permission, and while permission is not granted, Sync now reopens the consent prompt instead of downloading — so the question is always attached to the download it governs.
- Filters and actions across the generation lifecycle.
Security
Section titled “Security”- The sample-data importer validates every archive entry before extraction, rejecting absolute paths and
..traversal segments so a crafted ZIP cannot write outside the sample-data directory (zip-slip / path traversal).