---
topic: shipping
title: Creating, duplicating and deleting shipping methods
summary: One controller branch per action, four inputs that must all be present or nothing is written, and two traps that only bite non-browser callers.
appliesTo:
  - shipping.method.save
  - shipping.method.clone
  - shipping.method.delete
derivedFrom:
  - includes/src/Router/Controller/Backend/ShippingMethodsController.php:770-1013
  - includes/src/Router/Controller/Backend/ShippingMethodsController.php:1016-1072
  - includes/src/Checkout/Versandart.php:218-241
  - includes/src/Checkout/Versandart.php:263-303
  - includes/src/Backend/ShippingClassWizard/Wizard.php:81-89
  - includes/src/Country/Manager.php:199-227
  - admin/templates/bootstrap/tpl_inc/versandarten_neue_Versandart.tpl
  - admin/templates/bootstrap/tpl_inc/versandarten_uebersicht.tpl
---

## Domain model

A shipping method is one `tversandart` row plus four dependent tables: `tversandartsprache`
(localised name, delivery time, notes), `tversandartzahlungsart` (which payment methods it is
offered with, and their per-method surcharge), `tversandartstaffel` (the price scale) and
`tversandzuschlag` (per-country ZIP surcharges, which have their own guide — `shipping-surcharge`).

All three actions post to the same route, `shippingmethods`, and the controller picks a branch by
looking for a dispatch key in `$_POST`: `neueVersandart` saves, `clone` duplicates, `del` deletes
(`ShippingMethodsController.php:1016-1072`). Exactly one dispatch key is set per call and the
catalog sets it for you — that is why `neueVersandart` is declared `type: const` and why supplying
it yourself is refused.

`kVersandberechnung` decides what a valid method looks like:

| id | calculation | writes `tversandartstaffel`? |
|----|-------------|------------------------------|
| 1 | flat rate per country | **no** — use `fPreis` |
| 2 | by weight | yes |
| 3 | by cart value | yes |
| 4 | by item count | yes |

## Before you start

Read the method first — `list_shipping_methods` for the overview, `get_shipping_method` for one
method in full (tiers, ZIP increments, per-language texts, payment surcharges, restrictions). A save
is a **replace**, so what you do not read back and send again is deleted.

Resolve the remaining ids with `run_sql_read` — never guess them:

- payment methods: `SELECT kZahlungsart, cName FROM tzahlungsart WHERE nActive = 1`
- existing methods: `SELECT kVersandart, cName, kVersandberechnung FROM tversandart`
- calculation types: `SELECT kVersandberechnung, cName FROM tversandberechnung`

The shop must have at least one language; `cName_{code}` is templated on the installed language
codes (`ger`, `eng`, …).

## Creating or updating a method — `shipping.method.save`

1. Pick `kVersandberechnung` from the table above.
2. Choose `land` (ISO country codes) and `kZahlungsart` (payment method ids). **Both are required.**
3. For `kVersandberechnung` 2/3/4 build the price scale: `bis` and `preis` are PARALLEL arrays of
   equal length. For 1 (flat rate) they are ignored — set `fPreis` instead.
4. Set `cName`, and `cName_` as a map of language code to localised name for anything you want
   translated.
5. Send `kVersandart: 0` to create, or an existing id to update.
6. Call `validate_admin_action` with the same `{id, args}` — see below.
7. Call `invoke_admin_action` and check `verify.ok`, not just `ok`.

### Validate before you save

`validate_admin_action({id: "shipping.method.save", args})` reads only, writes nothing and costs a
few hundred tokens. It catches the two failures this action is actually lost to, neither of which
`invoke_admin_action`'s own argument binding sees:

- **an id that is not in its enum table** — `kVersandberechnung: 99` is refused by name, and the
  legal values are listed back to you. The binder cannot check this; resolving the table needs a
  database, which it deliberately does not hold.
- **parallel arrays of different length** — `bis` with two entries and `preis` with one is refused
  before the save quietly writes a broken scale.

It also reports what it could NOT check under `unchecked`, and says so explicitly: `valid: true`
means "nothing the catalog can check is wrong", not that the save will succeed. The four-input rule
below is prose, so it lands in `unchecked` — read that list, do not skip it. `verify.ok` after the
invocation is still what proves the write landed.

### The four-input rule

`cName`, `land`, `kZahlungsart` and a usable price basis must ALL be present. If any one is missing
the controller adds an error alert and writes **nothing** — it does not throw, and older shop code
paths return a success-looking object anyway (`:955-979`). This is the single most common way a
save appears to work and does not. The entry's `verify:` read-back exists precisely to catch it.

### An update is a replace, not a patch

For `kVersandart > 0` the controller DELETES and re-inserts every payment mapping and every price
scale (`:895-896`). Send the complete set every time; sending a delta silently removes the rest.

### Two traps that only bite non-browser callers

**`wizardShippingMethodDefinition` must be sent, as an empty string.**
`Request::postVar('wizardShippingMethodDefinition', [])` (`:999`) defaults to an **array**, and hands
it to `Wizard::save(int $shippingMethodID, string $formData, string $resultHash)`
(`Wizard.php:81`) — a `TypeError`. The admin UI never reaches it because
`versandarten_neue_Versandart.tpl:90` always posts the key as an empty hidden input. The catalog
declares the field `required: true` so you get a bindable refusal instead of a fatal.

**A flat-rate method writes no price scale, and that is correct.**
`createOrUpdate()` only builds `$shippingScales` for the weight/cart-value/item-count modules
(`:816-846`). An empty `tversandartstaffel` after a `kVersandberechnung: 1` save is the expected
outcome, not a failed write.

### Side effect beyond the method

Saving opens the chosen countries for customer registration:
`updateRegistrationCountries()` flips `tland.bPermitRegistration`
(`:903-911` → `Country/Manager.php:199-227`). Deleting re-runs the same recomputation and can close
a country again. That table is shared with the rest of the shop.

## Duplicating a method — `shipping.method.clone`

One argument: `clone`, the `kVersandart` to duplicate. Everything else is copied.

`Versandart::cloneShipping()` (`Versandart.php:218-241`) reads the source row, unsets its primary
key, re-inserts it, then copies `tversandartsprache`, `tversandartstaffel`,
`tversandartzahlungsart` and `tversandzuschlag` — and, through
`cloneShippingSectionSpecial()` (`:290-303`), each surcharge's ZIP and language rows too. It is a
deep copy, not a shallow one.

The copy keeps the source's `cName` **verbatim**. Two identically named shipping methods are the
normal outcome; rename the copy with `shipping.method.save` afterwards if that matters.

An id no `tversandart` row has is not a hard failure: `cloneShipping()` returns `false`
(`:226-240`) and the controller raises `errorShippingMethodDuplicated` (`:1045-1048`). Read
`verify`, not just `ok`.

## Deleting a method — `shipping.method.delete`

One argument: `del`. This entry is `Destructive`, so it needs `confirm: true` and it is refused
outright while the plugin runs in `limited` mode.

`Versandart::deleteInDB()` removes the method and its language, scale and payment-mapping rows.
The controller always reports success — an id that matches nothing deletes nothing and still
alerts `successShippingMethodDelete` (`:1024-1030`). Only the read-back tells you what happened.

Deleting the last method serving a country clears that country's `bPermitRegistration`
(`Country/Manager.php:220-226`), which stops new customers registering with that country. That is
a shop-wide effect, and it is the reason `tland` is in every shipping action's journal scope: an
undo restores those flags along with the method's own rows.

## What is not reachable from here

- **Shipping classes** (`tversandklasse`) are referenced by `kVersandklasse` but not created here.
- **The shipping-class wizard** definition is produced by its own UI; there is no supported way to
  construct one from an MCP call. Send an empty string.
- **Per-country ZIP surcharges** have their own actions and their own guide: `shipping-surcharge`.
- **Cache flushes are not transactional.** Every one of these actions flushes
  `CACHING_GROUP_OPTION`, `CACHING_GROUP_ARTICLE` and `CACHING_GROUP_SHIPPING`, plus the country
  cache. A `dryRun` rolls back the rows and does not un-flush the cache; the result says
  `cacheFlushed: true` rather than pretending otherwise.

## Undoing a shipping change

Every successful `invoke_admin_action` returns a `changeId` together with an `undo` block naming
what it can and cannot put back. `undo_admin_action({changeId})` returns the plan and changes
nothing; add `confirm: true` to apply it. `list_recent_changes` finds a changeId you no longer hold.

An undo restores rows in `tversandart`, `tversandartsprache`, `tversandartzahlungsart`,
`tversandartstaffel` and `tland`. It refuses outright — `refused: "superseded"` — if any of those
rows has been edited since, because replaying the old values over a later edit would destroy it.
The cache flush and the consumed auto-increment id are gone either way.
