silvershop-suite / import-excel
Spreadsheet (Excel/CSV/ODS) source connector for silvershop-suite/import — reads a workbook into the import engine (upsert by SKU/GTIN, column-mapped, validated, dry-run) AND provides the export→correct→re-apply roundtrip: a result/error workbook the client fixes and re-uploads, plus a full catalogu
Package info
github.com/silvershop-suite/silvershop-import-excel
Type:silverstripe-vendormodule
pkg:composer/silvershop-suite/import-excel
Requires
- php: ^8.3
- phpoffice/phpspreadsheet: ^2 || ^3
- silvershop-suite/import: ^1.1
- silvershop/core: ^6.2
- silverstripe/cms: ^6.0
- silverstripe/framework: ^6.0
Requires (Dev)
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-23 08:29:25 UTC
README
The spreadsheet source connector for silvershop-suite/import,
and its export → correct → re-apply roundtrip. It is the suite's MVP connector: it drives the whole
import engine (stage → map → upsert → dry-run → audit) with no external system — just a workbook.
Use it to:
- Bulk-load a catalogue from a supplier spreadsheet.
- Bulk-edit the live catalogue: export it, change it in Excel / LibreOffice / Numbers, re-import it.
- Update stock and prices on a schedule from a dropped file.
- Fix-and-retry: a run flags the bad rows in a result workbook; you correct those rows and re-upload.
It reads and writes .xlsx, .csv and .ods.
Contents
- For developers — install, configure, extend
- For shop managers / editors — the day-to-day spreadsheet workflow
For developers
Install
composer require silvershop-suite/import-excel
This pulls in the core engine (silvershop-suite/import) and phpoffice/phpspreadsheet. Run
vendor/bin/sake db:build flush=all afterwards.
How it fits the engine
The core silvershop-suite/import is a two-phase stage → apply pipeline that knows nothing about
spreadsheets. This module registers one ImportSource under the key excel
(SilverShop\Import\Excel\ExcelImportSource). Read the core README
for the engine model (profiles, staging, runs, ownership, scheduling); this README covers only the
spreadsheet specifics.
The ExcelImportSource reads the workbook's active sheet, uses the header row as source field names,
and yields one SourceRecord per data row. The profile's FieldMappings then turn those source columns
into silvershop target fields during the apply phase.
Configure a profile
An ImportProfile (managed in ImportAdmin → Imports) is the unit of configuration:
| Field | For Excel, set to… |
|---|---|
| SourceKey | excel |
| SourceLocation | Absolute path to the workbook (e.g. /var/imports/catalogue.xlsx). |
| Domains | Comma list of what to sync: Product,Variation,Stock (and/or Price). |
| WriteMode | upsert (create + update), add-only, or update-only. |
| MissingPolicy | none (leave missing records alone — safest), deactivate, or delete. |
| KeyStrategy | sku,gtin — resolve by SKU, then GTIN. (sku alone for product-only sheets.) |
| OwnershipPreset | full-erp (sheet wins on every field), pos-price, stock-only, or project-default. |
| Enabled | true |
Tip: for a bulk-edit roundtrip you almost always want
WriteMode=upsert,MissingPolicy=none,KeyStrategy=sku,gtin,OwnershipPreset=full-erp.
Field mappings
Each FieldMapping row (per profile) maps one source column → one target field, scoped to a
domain, with a Transform, optional ValidationRules, and an Ownership override. For the grouped
catalogue shape a typical set is:
| Domain | Source column | Target field | Transform | Note |
|---|---|---|---|---|
| Product | InternalItemID |
InternalItemID |
trim |
the SKU — the Product key |
| Product | Title |
Title |
trim |
|
| Product | BasePrice |
BasePrice |
number |
|
| Product | GTIN |
GTIN |
trim |
a simple product's own barcode (optional) |
| Product | Category |
Category |
trim |
pseudo-target → category resolver |
| Product | Brand |
Brand |
trim |
pseudo-target → brand resolver |
| Variation | InternalItemID |
ProductSKU |
trim |
the parent SKU → resolves the product |
| Variation | GTIN |
GTIN |
trim |
the variation key |
| Variation | Price |
Price |
number |
|
| Variation | Option1Name |
Option1Name |
trim |
axis label (→ Option2/3 for more) |
| Variation | Option1Value |
Option1Value |
trim |
axis value |
| Stock | GTIN |
GTIN |
trim |
the stock key |
| Stock | Quantity |
Quantity |
integer |
pseudo-target → stock writer |
Pseudo-targets (Category, Brand, Quantity/Stock, ProductSKU, Option{N}Name/Value) aren't
database fields — the engine routes them to the category/brand/stock/attribute resolvers. All other target
fields are written straight onto the Product / Variation.
The column schema
The grouped export produces these columns (order configurable, see YAML):
record_type, InternalItemID, Title, BasePrice, GTIN, Price, Quantity,
Category, Brand, Option1Name, Option1Value, Option2Name, Option2Value, Option3Name, Option3Value
- Category paths use
>between levels and|between multiple paths (configurable onCategoryResolver). The first path's leaf becomes the product's primary parent (ParentID); every leaf is added as aProductCategoriesmembership. Missing categories are created. - Brand matches an existing brand by
Title(creates it if missing). Requiressilvershop-suite/brands; without it, theBrandcolumn is ignored. - Option{N} axes map to silvershop variation attributes (
AttributeType+AttributeValue), up toAttributeResolver.max_axes(default 3). - Quantity writes through
silvershop/stock(setStockLevel()); requires that module and a configured warehouse. Without it, the column is ignored. - GTIN keying/validation requires
silvershop-suite/product-identity.
Domains & key strategy
A single profile can sync several domains; each run processes one domain. List the Product domain before Variation so parent products exist before their variations are linked (a missing parent is created minimally regardless).
| Domain | Rows it reads (by record_type) |
Keyed by | Resolves/writes |
|---|---|---|---|
| Product | product / blank |
SKU (InternalItemID) |
create/update products + category + brand |
| Variation | variant |
GTIN | create/update variations under their parent SKU |
| Stock | every row with a key | GTIN (then SKU) | setStockLevel() on the matched buyable |
| Price | product / blank |
SKU | folds Price/BasePrice onto the product |
The upsert key column is chosen per domain from the mappings (a field mapped to InternalItemID, else
GTIN, else the first column) — so for the Variation/Stock domains, mapping GTIN (and not
InternalItemID) makes the rows key on GTIN. A varianted product's parent row has a blank GTIN and is
therefore skipped by the Stock domain (its stock lives on the variants).
record_type routing
The reader splits rows by the record_type column (configurable name):
product,simple, or blank → applied on the Product domain.variantorvariation→ applied on the Variation domain.- The Stock / Price domains take every keyed row regardless of
record_type.
A sheet with no record_type column at all is a flat product-only catalogue — every row is a product
(handy for a quick price/stock update). It imports unchanged on the Product domain and yields nothing on
the Variation domain.
Transforms
Set per FieldMapping.Transform; unknown transforms (and null values) pass through unchanged:
trim · number (float) · integer · boolean (1/true/yes/y) · uppercase · lowercase ·
date (normalises to Y-m-d H:i:s).
Exporters & tasks
Two writers back the roundtrip, both exposed through one task:
ExcelCatalogueExporter— the full catalogue in the grouped shape (product + variant rows). An editing template / pre-change backup.ExcelResultWriter— a profile+domain's staged rows written back with_Statusand_Errorcolumns appended (the fix-and-re-upload workbook).
# Export the current catalogue as an editing template sake tasks:SilverShop-Import-Excel-Export --type=catalogue --path=/tmp/catalogue.xlsx # After a run (or a --dry-run), export the result workbook with per-row status/errors sake tasks:SilverShop-Import-Excel-Export --type=result --profile=<id|key> --path=/tmp/result.xlsx # Preview (stage + simulate, writes nothing), then apply — from the core engine: sake tasks:SilverShop-Import-Run --profile=<id|key> --dry-run sake tasks:SilverShop-Import-Run --profile=<id|key>
--type is catalogue (default) or result; --domain narrows a result export; --path is optional
(auto-named under import-exports/ if omitted).
All exported cells are written formula-injection-safe (see Security).
YAML configuration
# This module — the record_type column name and the export column set. SilverShop\Import\Excel\ExcelImportSource: record_type_column: 'record_type' SilverShop\Import\Excel\Export\ExcelCatalogueExporter: columns: - record_type - InternalItemID - Title - BasePrice - GTIN - Price - Quantity - Category - Brand - Option1Name - Option1Value # Core resolvers that shape how the sheet's Category / Option columns are interpreted. SilverShop\Import\Service\CategoryResolver: path_separator: ' > ' paths_separator: '|' category_parent_id: 0 # create new categories under this ProductCategory (0 = site root) create_missing: true SilverShop\Import\Service\AttributeResolver: max_axes: 3 # Project default for who-wins-on-a-field (see the core README). SilverShop\Import\Service\OwnershipResolver: default_ownership: BasePrice: erp-authoritative Title: erp-authoritative
Security
- Spreadsheet formula injection (export). Every exported cell whose text starts with
=,+,-,@, tab or CR is written as an explicit literal string, so a crafted field value can't execute as a formula when the workbook is opened. This is a fixed policy, not a config toggle. - XXE (import). Workbook XML is parsed by phpspreadsheet, which disables external-entity loading by
default — keep it patched (
^2 || ^3). SourceLocationis an arbitrary local path read with the web user's permissions. It is set inImportAdmin, which requires CMS access — treat who can edit profiles as a trust boundary.
Extending
- Change the detected columns — override
ExcelCatalogueExporter.columnsin YAML, or subclass it. - Rename the type column — set
ExcelImportSource.record_type_column. - A different spreadsheet layout — write your own
ImportSource(implement the interface, register it under a new key); you don't have to use this reader to use the engine. - Multiple supplier layouts — one profile per layout, each with its own mappings, all using the
excelsource.
Requirements
- PHP
^8.3·silvershop/core^6.2·silvershop-suite/import phpoffice/phpspreadsheet^2 || ^3(the only dependency this connector adds)- Optional (guarded, per column):
silvershop/stock(Quantity),silvershop-suite/product-identity(GTIN),silvershop-suite/brands(Brand)
For shop managers / editors
You don't need to know anything about code to use this. You work in a spreadsheet; the shop imports it.
The workflow
┌─────────────┐ export ┌──────────────┐ edit in ┌──────────────┐ re-import ┌─────────────┐
│ Your shop │ ──────────► │ catalogue │ ───────────► │ edited │ ──────────► │ Your shop │
│ (products) │ │ .xlsx │ Excel/Calc │ .xlsx │ │ (updated) │
└─────────────┘ └──────────────┘ └──────────────┘ └─────────────┘
- Export the current catalogue to a spreadsheet (your developer sets this up, or runs it for you —
see the tasks). You get an
.xlsxwith one row per product and one row per product variation. - Edit it in Excel, LibreOffice Calc or Numbers. Change prices, titles, stock, categories, brands — whatever you need. Save it.
- Re-import it. The shop reads your changes back in. Rows you didn't touch stay exactly as they were.
The import is safe to repeat: importing the same unchanged file twice does nothing the second time, and a run never deletes products just because they're missing from your sheet (unless your developer turned that on deliberately).
Understanding the spreadsheet
The first row is the header. Every row after it is one product or one variation. Here's a small example — a trail-running shoe that comes in two sizes, and a one-size sock:
| record_type | InternalItemID | Title | BasePrice | GTIN | Price | Quantity | Category | Brand | Option1Name | Option1Value |
|---|---|---|---|---|---|---|---|---|---|---|
| product | SHOE-01 | Trail Runner | 89.95 | Footwear > Trail | Acme | |||||
| variant | SHOE-01 | 4006381333931 | 89.95 | 12 | Size | 42 | ||||
| variant | SHOE-01 | 4012345678901 | 94.95 | 7 | Size | 43 | ||||
| product | SOCK-01 | Merino Sock | 14.95 | 5901234123457 | 40 | Accessories | Acme |
What each column means:
| Column | What it is |
|---|---|
| record_type | product for a product row, variant for one of its variations. (Blank counts as product.) |
| InternalItemID | The product code / SKU. On a variant row this is the parent product's SKU (repeated). |
| Title | The product name. (Only on product rows.) |
| BasePrice | The product's price. (Only on product rows.) |
| GTIN | The barcode / EAN — the unique identity of a variation. A simple product may have its own. |
| Price | A variation's price. (Only on variant rows.) |
| Quantity | Stock level. On a variant row = that variation's stock; on a simple product = the product's. |
| Category | Where the product lives. Parent > Child for a path; A > B | C to put it in several. |
| Brand | The brand name. |
| Option1Name / Value | A variation's choice — e.g. Size = 42, or Colour = Red. Up to Option3 for two more axes. |
Product rows vs. variant rows
A product that has variations (sizes, colours…) is written as one product row followed by one
variant row per variation:
- The
productrow carries the shared stuff: title, base price, category, brand. - Each
variantrow repeats the parent's SKU inInternalItemID, and carries its own GTIN (barcode), price, stock, and its option values (Size = 42).
A product with no variations is a single product row — put its stock, and its own barcode, right
on that row (like SOCK-01 above).
The GTIN is how the shop recognises a variation. Two variations of the same product share the SKU but have different GTINs — that's what tells them apart.
How to make common edits
- Change a price — edit
BasePriceon a product row, orPriceon a variant row. - Change a title — edit
Titleon the product row. - Change stock — edit
Quantity(on the variant row for a varied product, on the product row for a simple one). - Move / set a category — edit
Category. UseMen > Shoesfor a nested path. A category that doesn't exist yet is created for you. Put a product in two categories with a pipe:Men > Shoes | Sale. - Change the brand — edit
Brand(the brand name). A new brand name creates/assigns that brand. - Add a new variation to an existing product — add a new
variantrow with the parent's SKU inInternalItemID, a new unique GTIN, its price, stock and option values. - Add a whole new product — add a
productrow (and, if it has variations, thevariantrows under it). Give it a new SKU.
The golden rules
- Don't change a GTIN or a SKU to "rename" something. They are identities. Change a SKU and the
shop thinks it's a different product; change a GTIN and it's a different variation. To rename the
display name, edit
Title, not the codes. - Keep each variant row's
InternalItemIDequal to its parent product's SKU. That's the link between a variation and its product. - A GTIN must be a valid barcode (8–14 digits with a correct check digit). An invalid one is rejected and that row is flagged — the rest of the import still goes through.
- Leaving a cell blank doesn't delete anything — a blank price/stock is simply "no change intended"
where the column doesn't apply (e.g.
Priceon a product row). Deleting a product is not done by removing its row; ask your developer about the missing-row policy. - Don't rename or reorder the header row. The shop matches columns by their header names.
Re-importing & fixing errors
When the file is re-imported, you (or your developer) can run a preview first — it checks every row
and writes nothing. If some rows have problems (a bad barcode, a price that isn't a number…), you get a
result workbook: the same rows with two extra columns, _Status and _Error, telling you
exactly which rows failed and why.
Fix just those rows in the result workbook, save, and re-import. Rows you already got right are recognised
as unchanged and skipped — only the fixed rows are retried. Repeat until every row says Applied.
FAQ
- Do I have to include every product every time? No. You can import a sheet with only the rows you want to change (e.g. a price-and-stock update for ten products). Untouched products are left alone.
- Can I just update stock? Yes — a flat sheet with
InternalItemID/GTIN+Quantitycolumns works; you don't need the variation structure for a stock-only update. - What happens to a product that's missing from my sheet? By default, nothing (it's left as-is). Your developer can configure a run to deactivate or remove missing products, but that's off unless chosen.
- I changed a variation's colour from Red to Green and nothing happened. Changing an option value on an existing variation isn't an update in this version — it would be a new variation. Price, stock and the product-level fields update fine; option changes mean adding a new variant row.
Credits
Authored by Remy Vaartjes and the SilverShop-Suite Contributors. Licensed BSD-3-Clause.